Skip to main content

rusmes_jmap/
api.rs

1//! JMAP API server
2//!
3//! This module implements the JMAP (JSON Meta Application Protocol) API server
4//! as defined in RFC 8620. It provides comprehensive request validation including:
5//!
6//! - Request structure validation (using, methodCalls)
7//! - Capability validation (ensuring declared capabilities are supported)
8//! - Method call validation (structure, limits, arguments)
9//! - Error responses per RFC 8620 Section 3.6:
10//!   - `unknownCapability`: Capability not recognized
11//!   - `notRequest`: Request doesn't match JMAP structure
12//!   - `limit`: Server limit exceeded (e.g., maxCallsInRequest)
13//!   - `unknownMethod`: Method not recognized
14//!   - `invalidArguments`: Invalid method arguments
15//!   - Other error types for account/server issues
16
17use crate::session::Session;
18use crate::types::{JmapError, JmapErrorType, JmapRequest, JmapResponse};
19use axum::{
20    extract::Json,
21    http::{HeaderMap, StatusCode},
22    response::{IntoResponse, Response},
23    routing::{get, post},
24    Router,
25};
26use base64::{engine::general_purpose, Engine as _};
27
28/// JMAP server
29pub struct JmapServer;
30
31impl JmapServer {
32    /// Create JMAP routes
33    pub fn routes() -> Router {
34        Router::new()
35            .route("/.well-known/jmap", get(session_endpoint))
36            .route("/jmap", post(api_endpoint))
37    }
38}
39
40/// Session discovery endpoint (RFC 8620 Section 2)
41/// Returns a Session object describing the server's capabilities,
42/// accounts, and API endpoints.
43async fn session_endpoint(headers: HeaderMap) -> Json<Session> {
44    // In a real implementation:
45    // 1. Extract authentication token from Authorization header
46    // 2. Validate the token and get the authenticated user
47    // 3. Query the database for user's accounts
48    // 4. Build session object with actual user data
49
50    // For now, return a basic session for demonstration
51    let username =
52        extract_username_from_headers(&headers).unwrap_or_else(|| "user@example.com".to_string());
53
54    let account_id = format!("account-{}", username.replace('@', "-"));
55    let base_url = "https://jmap.example.com".to_string();
56
57    let session = Session::new(username, account_id, base_url);
58
59    Json(session)
60}
61
62/// Extract and validate username from Authorization header
63///
64/// NOTE: This is a simplified implementation for development.
65/// In production, this should integrate with rusmes-auth::AuthBackend
66/// to properly validate credentials against the configured backend
67/// (file, LDAP, SQL, OAuth2, etc.)
68fn extract_username_from_headers(headers: &HeaderMap) -> Option<String> {
69    // Check for Basic auth
70    if let Some(auth) = headers.get("authorization") {
71        if let Ok(auth_str) = auth.to_str() {
72            if let Some(encoded) = auth_str.strip_prefix("Basic ") {
73                // Basic auth format: "Basic base64(username:password)"
74                if let Ok(decoded) = general_purpose::STANDARD.decode(encoded) {
75                    if let Ok(credentials) = String::from_utf8(decoded) {
76                        let parts: Vec<&str> = credentials.splitn(2, ':').collect();
77                        if parts.len() == 2 {
78                            let username = parts[0];
79                            let password = parts[1];
80
81                            // DEVELOPMENT ONLY: Simple validation
82                            // In production, replace this with:
83                            //   auth_backend.authenticate(username, password).await
84                            if validate_basic_auth(username, password) {
85                                return Some(username.to_string());
86                            } else {
87                                tracing::warn!(
88                                    "Invalid Basic auth credentials for user: {}",
89                                    username
90                                );
91                                return None;
92                            }
93                        }
94                    }
95                }
96            } else if auth_str.starts_with("Bearer ") {
97                // Bearer token format: "Bearer <token>"
98                let token = auth_str.strip_prefix("Bearer ").unwrap_or("");
99
100                // DEVELOPMENT ONLY: Simple token validation
101                // In production, replace this with proper JWT validation:
102                //   - Verify signature with public key
103                //   - Check expiration (exp claim)
104                //   - Validate issuer (iss claim)
105                //   - Extract username from subject (sub claim)
106                if let Some(username) = validate_bearer_token(token) {
107                    return Some(username);
108                } else {
109                    tracing::warn!("Invalid Bearer token");
110                    return None;
111                }
112            }
113        }
114    }
115
116    None
117}
118
119/// Validate Basic authentication credentials
120///
121/// DEVELOPMENT ONLY: Returns true for any non-empty credentials.
122/// In production, integrate with AuthBackend:
123///   auth_backend.authenticate(username, password).await
124fn validate_basic_auth(username: &str, password: &str) -> bool {
125    // Allow any non-empty credentials for development
126    // Real implementation would check against AuthBackend
127    !username.is_empty() && !password.is_empty()
128}
129
130/// Validate Bearer token and extract username
131///
132/// DEVELOPMENT ONLY: Accepts any token and returns dummy username.
133/// In production, implement proper JWT validation:
134///   - Decode JWT and verify signature
135///   - Check expiration, issuer, audience
136///   - Extract username from 'sub' or custom claim
137fn validate_bearer_token(_token: &str) -> Option<String> {
138    // In production, use jsonwebtoken crate:
139    //   let token_data = decode::<Claims>(token, &key, &validation)?;
140    //   Some(token_data.claims.sub)
141
142    // For now, return dummy username for development
143    Some("user@example.com".to_string())
144}
145
146/// Main JMAP API endpoint
147async fn api_endpoint(Json(request): Json<JmapRequest>) -> Response {
148    tracing::debug!(
149        "API_ENDPOINT: Received JMAP request with {} method calls",
150        request.method_calls.len()
151    );
152    // Validate the request structure (RFC 8620 Section 3.3)
153    if let Some(error_response) = validate_request(&request) {
154        tracing::debug!("API_ENDPOINT: Request validation failed");
155        return error_response;
156    }
157    tracing::debug!("API_ENDPOINT: Request validated successfully");
158
159    let mut response = JmapResponse {
160        method_responses: Vec::new(),
161        session_state: Some("state1".to_string()),
162        created_ids: request.created_ids.clone(),
163    };
164
165    // Process each method call
166    for method_call in request.method_calls {
167        let call_id = method_call.2.clone();
168
169        match crate::methods::dispatch_method(method_call, &request.using).await {
170            Ok(method_response) => {
171                response.method_responses.push(method_response);
172            }
173            Err(e) => {
174                tracing::error!("JMAP method error: {}", e);
175                // Return error response
176                response
177                    .method_responses
178                    .push(crate::types::JmapMethodResponse(
179                        "error".to_string(),
180                        serde_json::to_value(
181                            JmapError::new(JmapErrorType::ServerFail).with_detail(e.to_string()),
182                        )
183                        .unwrap_or_default(),
184                        call_id,
185                    ));
186            }
187        }
188    }
189
190    (StatusCode::OK, Json(response)).into_response()
191}
192
193/// Validate JMAP request structure and capabilities (RFC 8620 Section 3.3)
194fn validate_request(request: &JmapRequest) -> Option<Response> {
195    // Validate "using" capabilities
196    if request.using.is_empty() {
197        let error = JmapError::new(JmapErrorType::UnknownCapability)
198            .with_status(400)
199            .with_detail("The 'using' property must contain at least one capability");
200        return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
201    }
202
203    // Check for supported capabilities
204    let supported_capabilities = get_supported_capabilities();
205    for capability in &request.using {
206        if !supported_capabilities.contains(&capability.as_str()) {
207            let error = JmapError::new(JmapErrorType::UnknownCapability)
208                .with_status(400)
209                .with_detail(format!("Unsupported capability: {}", capability));
210            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
211        }
212    }
213
214    // Validate methodCalls structure
215    if request.method_calls.is_empty() {
216        let error = JmapError::new(JmapErrorType::NotRequest)
217            .with_status(400)
218            .with_detail("The 'methodCalls' property must contain at least one method call");
219        return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
220    }
221
222    // Check for method call limit (RFC 8620 Section 3.4)
223    const MAX_CALLS_IN_REQUEST: usize = 16;
224    if request.method_calls.len() > MAX_CALLS_IN_REQUEST {
225        let error = JmapError::new(JmapErrorType::Limit)
226            .with_status(400)
227            .with_detail(format!(
228                "Too many method calls. Maximum allowed: {}",
229                MAX_CALLS_IN_REQUEST
230            ))
231            .with_limit("maxCallsInRequest");
232        return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
233    }
234
235    // Validate each method call structure
236    for (idx, method_call) in request.method_calls.iter().enumerate() {
237        let method_name = &method_call.0;
238        let call_id = &method_call.2;
239
240        // Validate method name is not empty
241        if method_name.is_empty() {
242            let error = JmapError::new(JmapErrorType::NotRequest)
243                .with_status(400)
244                .with_detail(format!("Method call {} has empty method name", idx));
245            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
246        }
247
248        // Validate call ID is not empty
249        if call_id.is_empty() {
250            let error = JmapError::new(JmapErrorType::NotRequest)
251                .with_status(400)
252                .with_detail(format!("Method call {} has empty call ID", idx));
253            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
254        }
255
256        // Validate arguments is an object
257        if !method_call.1.is_object() {
258            let error = JmapError::new(JmapErrorType::InvalidArguments)
259                .with_status(400)
260                .with_detail(format!(
261                    "Method call {} ('{}') has invalid arguments - must be an object",
262                    idx, method_name
263                ));
264            return Some((StatusCode::BAD_REQUEST, Json(error)).into_response());
265        }
266    }
267
268    None
269}
270
271/// Get the list of supported JMAP capabilities
272fn get_supported_capabilities() -> Vec<&'static str> {
273    vec![
274        "urn:ietf:params:jmap:core",
275        "urn:ietf:params:jmap:mail",
276        "urn:ietf:params:jmap:submission",
277        "urn:ietf:params:jmap:vacationresponse",
278    ]
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_jmap_server_routes() {
287        let _router = JmapServer::routes();
288        // Router created successfully
289    }
290}