Skip to main content

fraiseql_core/security/auth_middleware/
middleware.rs

1//! Authentication middleware implementation.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use jsonwebtoken::{Validation, decode};
7
8use super::{
9    config::AuthConfig,
10    signing_key::SigningKey,
11    types::{
12        AuthRequest, AuthenticatedUser, JwtClaims, TokenClaims, extract_claim_string,
13        extract_name_string,
14    },
15};
16use crate::security::errors::{Result, SecurityError};
17
18/// Authentication Middleware
19///
20/// Validates incoming requests for authentication requirements.
21/// Acts as the second layer in the security middleware pipeline.
22#[derive(Debug, Clone)]
23pub struct AuthMiddleware {
24    config: AuthConfig,
25}
26
27impl AuthMiddleware {
28    /// Create a new authentication middleware from configuration.
29    ///
30    /// Emits a warning when `required = true` but no signing key is configured,
31    /// because JWT signature verification will be disabled in that case.
32    #[must_use]
33    pub fn from_config(config: AuthConfig) -> Self {
34        if config.required && config.signing_key.is_none() {
35            tracing::warn!(
36                "AuthMiddleware: required=true but no signing_key configured — \
37                 JWT signatures will NOT be verified"
38            );
39        }
40        Self { config }
41    }
42
43    /// Create middleware with permissive settings (authentication optional)
44    #[must_use]
45    pub fn permissive() -> Self {
46        Self::from_config(AuthConfig::permissive())
47    }
48
49    /// Create middleware with standard settings (authentication required)
50    #[must_use]
51    pub fn standard() -> Self {
52        Self::from_config(AuthConfig::standard())
53    }
54
55    /// Create middleware with strict settings (authentication required, short expiry)
56    #[must_use]
57    pub fn strict() -> Self {
58        Self::from_config(AuthConfig::strict())
59    }
60
61    /// Validate authentication in a request.
62    ///
63    /// Performs validation checks in order:
64    /// 1. Extract token from Authorization header
65    /// 2. Validate token signature (if signing key configured)
66    /// 3. Check token expiry (exp claim)
67    /// 4. Validate issuer/audience claims (if configured)
68    /// 5. Extract required claims (sub)
69    /// 6. Extract optional claims (scope, aud, iss)
70    ///
71    /// # Errors
72    ///
73    /// Returns [`SecurityError::AuthRequired`] if no Authorization header or
74    /// token is missing. Returns [`SecurityError::InvalidToken`] if the token
75    /// signature, expiry, issuer, or audience is invalid. Returns
76    /// [`SecurityError::TokenMissingClaim`] if a required claim is absent.
77    pub fn validate_request(&self, req: &AuthRequest) -> Result<AuthenticatedUser> {
78        // Check 1: Extract token from Authorization header
79        let token = self.extract_token(req)?;
80
81        // Check 2: Validate token (with or without signature verification)
82        if let Some(ref signing_key) = self.config.signing_key {
83            // Use jsonwebtoken crate for proper signature verification
84            self.validate_token_with_signature(&token, signing_key)
85        } else {
86            // Fallback: structure validation only (for testing/backwards compatibility)
87            // WARNING: This is insecure for production use!
88            self.validate_token_structure_only(&token)
89        }
90    }
91
92    /// Validate token with cryptographic signature verification.
93    ///
94    /// This is the secure path used when a signing key is configured.
95    fn validate_token_with_signature(
96        &self,
97        token: &str,
98        signing_key: &SigningKey,
99    ) -> Result<AuthenticatedUser> {
100        // Get the decoding key
101        let decoding_key = signing_key.to_decoding_key()?;
102
103        // Build validation configuration
104        let mut validation = Validation::new(signing_key.algorithm());
105
106        // Configure issuer validation (only validate if configured)
107        if let Some(ref issuer) = self.config.issuer {
108            validation.set_issuer(&[issuer]);
109        }
110        // Note: If issuer is not set, validation.iss is None and won't be validated
111
112        // Configure audience validation.
113        // SECURITY: `validate_aud = true` is the default in jsonwebtoken; we must
114        // NOT override it to `false` when no audience is configured, as that would
115        // silently accept tokens issued for any service (cross-service token replay).
116        // When no audience is pinned, any non-empty `aud` claim is accepted — callers
117        // should set `audience` in config to restrict this further.
118        if let Some(ref audience) = self.config.audience {
119            validation.set_audience(&[audience]);
120        }
121        // `validation.validate_aud` remains `true` (the library default) when no
122        // specific audience is configured.
123
124        // Set clock skew tolerance
125        validation.leeway = self.config.clock_skew_secs;
126
127        // Decode and validate the token
128        let token_data = decode::<JwtClaims>(token, &decoding_key, &validation).map_err(|e| {
129            match e.kind() {
130                jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
131                    // Try to extract the actual expiry time from the token
132                    SecurityError::TokenExpired {
133                        expired_at: Utc::now(), // Approximate - actual time is not accessible
134                    }
135                },
136                jsonwebtoken::errors::ErrorKind::InvalidSignature => {
137                    SecurityError::JwtSignatureInvalid
138                },
139                jsonwebtoken::errors::ErrorKind::InvalidIssuer => {
140                    SecurityError::JwtIssuerMismatch {
141                        expected: self
142                            .config
143                            .issuer
144                            .clone()
145                            .unwrap_or_else(|| "(not configured)".to_string()),
146                    }
147                },
148                jsonwebtoken::errors::ErrorKind::InvalidAudience => {
149                    SecurityError::JwtAudienceMismatch {
150                        expected: self
151                            .config
152                            .audience
153                            .clone()
154                            .unwrap_or_else(|| "(not configured)".to_string()),
155                    }
156                },
157                jsonwebtoken::errors::ErrorKind::InvalidAlgorithm => {
158                    SecurityError::InvalidTokenAlgorithm {
159                        algorithm: format!("{:?}", signing_key.algorithm()),
160                    }
161                },
162                jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claim) => {
163                    SecurityError::TokenMissingClaim {
164                        claim: claim.clone(),
165                    }
166                },
167                _ => SecurityError::InvalidToken,
168            }
169        })?;
170
171        let claims = token_data.claims;
172
173        // Extract scopes (supports multiple formats)
174        let scopes = self.extract_scopes_from_jwt_claims(&claims);
175
176        // Extract user ID (required)
177        let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
178            claim: "sub".to_string(),
179        })?;
180        let user_id = crate::types::UserId::new(user_id_str);
181
182        // Extract expiration (required)
183        let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
184            claim: "exp".to_string(),
185        })?;
186
187        let expires_at =
188            DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
189
190        let email = claims.extra.get("email").and_then(extract_claim_string);
191        let display_name = claims.extra.get("name").and_then(extract_name_string);
192
193        Ok(AuthenticatedUser {
194            user_id,
195            scopes,
196            expires_at,
197            email,
198            display_name,
199            extra_claims: claims.extra,
200        })
201    }
202
203    /// Extract scopes from JWT claims.
204    ///
205    /// Supports multiple formats:
206    /// - `scope`: space-separated string (`OAuth2` standard)
207    /// - `scp`: array of strings (Microsoft)
208    /// - `permissions`: array of strings (Auth0 RBAC)
209    fn extract_scopes_from_jwt_claims(&self, claims: &JwtClaims) -> Vec<String> {
210        // Try space-separated scope string first (most common)
211        if let Some(ref scope) = claims.scope {
212            return scope.split_whitespace().map(String::from).collect();
213        }
214
215        // Try array of scopes (scp claim)
216        if let Some(ref scp) = claims.scp {
217            return scp.clone();
218        }
219
220        // Try permissions array (Auth0 RBAC)
221        if let Some(ref permissions) = claims.permissions {
222            return permissions.clone();
223        }
224
225        Vec::new()
226    }
227
228    /// Validate token structure only (no signature verification).
229    ///
230    /// WARNING: This is insecure and should only be used for testing
231    /// or when signature verification is handled elsewhere.
232    fn validate_token_structure_only(&self, token: &str) -> Result<AuthenticatedUser> {
233        // Validate basic structure
234        self.validate_token_structure(token)?;
235
236        // Parse claims
237        let claims = self.parse_claims(token)?;
238
239        // Extract and validate 'exp' claim (required)
240        let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
241            claim: "exp".to_string(),
242        })?;
243
244        // Check expiry
245        let expires_at =
246            DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
247
248        if expires_at <= Utc::now() {
249            return Err(SecurityError::TokenExpired {
250                expired_at: expires_at,
251            });
252        }
253
254        // Extract and validate 'sub' claim (required)
255        let user_id_str = claims.sub.ok_or(SecurityError::TokenMissingClaim {
256            claim: "sub".to_string(),
257        })?;
258        let user_id = crate::types::UserId::new(user_id_str);
259
260        // Extract optional claims
261        let scopes = claims
262            .scope
263            .as_ref()
264            .map(|s| s.split_whitespace().map(String::from).collect())
265            .unwrap_or_default();
266
267        Ok(AuthenticatedUser {
268            user_id,
269            scopes,
270            expires_at,
271            email: None,
272            display_name: None,
273            extra_claims: HashMap::new(),
274        })
275    }
276
277    /// Extract token from the authorization header
278    fn extract_token(&self, req: &AuthRequest) -> Result<String> {
279        // If auth is not required and no header present, that's OK
280        if !self.config.required && req.authorization_header.is_none() {
281            return Err(SecurityError::AuthRequired); // Will be handled differently
282        }
283
284        req.extract_bearer_token()
285    }
286
287    /// Validate token structure (basic checks)
288    ///
289    /// A real implementation would validate the signature here.
290    /// For now, we just check basic structure.
291    pub(crate) fn validate_token_structure(&self, token: &str) -> Result<()> {
292        // JWT has 3 parts separated by dots: header.payload.signature
293        let parts: Vec<&str> = token.split('.').collect();
294        if parts.len() != 3 {
295            return Err(SecurityError::InvalidToken);
296        }
297
298        // Check that each part is non-empty
299        if parts.iter().any(|p| p.is_empty()) {
300            return Err(SecurityError::InvalidToken);
301        }
302
303        Ok(())
304    }
305
306    /// Parse JWT claims (simplified, for demo purposes)
307    ///
308    /// In a real implementation, this would decode and validate the JWT signature.
309    /// For testing, we accept a special test token format: "`test:{json_payload`}"
310    fn parse_claims(&self, token: &str) -> Result<TokenClaims> {
311        // Split the token
312        let parts: Vec<&str> = token.split('.').collect();
313        if parts.len() != 3 {
314            return Err(SecurityError::InvalidToken);
315        }
316
317        // For testing, we use a simple format: part1.{json}.part3
318        // where {json} is a base64-like encoded JSON
319        // Since we don't have base64 in core dependencies, we'll try to parse directly
320        let payload_part = parts[1];
321
322        // Try to decode as hex (simpler than base64 and no dependencies)
323        // For test tokens, we'll encode the JSON as hex
324        if let Ok(decoded) = hex::decode(payload_part) {
325            if let Ok(json_str) = std::str::from_utf8(&decoded) {
326                if let Ok(json) = serde_json::from_str::<serde_json::Value>(json_str) {
327                    return Ok(self.extract_claims_from_json(&json));
328                }
329            }
330        }
331
332        // If hex decoding fails, try to parse as UTF-8 directly (for test tokens created inline)
333        if let Ok(json) = serde_json::from_str::<serde_json::Value>(payload_part) {
334            return Ok(self.extract_claims_from_json(&json));
335        }
336
337        Err(SecurityError::InvalidToken)
338    }
339
340    /// Extract claims from parsed JSON
341    fn extract_claims_from_json(&self, json: &serde_json::Value) -> TokenClaims {
342        let sub = json["sub"].as_str().map(String::from);
343        let exp = json["exp"].as_i64();
344        let scope = json["scope"].as_str().map(String::from);
345        let aud = json["aud"]
346            .as_array()
347            .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
348        let iss = json["iss"].as_str().map(String::from);
349
350        TokenClaims {
351            sub,
352            exp,
353            scope,
354            aud,
355            iss,
356        }
357    }
358
359    /// Get the underlying configuration
360    #[must_use]
361    pub const fn config(&self) -> &AuthConfig {
362        &self.config
363    }
364}