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::{AuthRequest, AuthenticatedUser, JwtClaims, TokenClaims},
12};
13use crate::security::errors::{Result, SecurityError};
14
15/// Authentication Middleware
16///
17/// Validates incoming requests for authentication requirements.
18/// Acts as the second layer in the security middleware pipeline.
19#[derive(Debug, Clone)]
20pub struct AuthMiddleware {
21    config: AuthConfig,
22}
23
24impl AuthMiddleware {
25    /// Create a new authentication middleware from configuration.
26    ///
27    /// Emits a warning when `required = true` but no signing key is configured,
28    /// because JWT signature verification will be disabled in that case.
29    #[must_use]
30    pub fn from_config(config: AuthConfig) -> Self {
31        if config.required && config.signing_key.is_none() {
32            tracing::warn!(
33                "AuthMiddleware: required=true but no signing_key configured — \
34                 JWT signatures will NOT be verified"
35            );
36        }
37        Self { config }
38    }
39
40    /// Create middleware with permissive settings (authentication optional)
41    #[must_use]
42    pub fn permissive() -> Self {
43        Self::from_config(AuthConfig::permissive())
44    }
45
46    /// Create middleware with standard settings (authentication required)
47    #[must_use]
48    pub fn standard() -> Self {
49        Self::from_config(AuthConfig::standard())
50    }
51
52    /// Create middleware with strict settings (authentication required, short expiry)
53    #[must_use]
54    pub fn strict() -> Self {
55        Self::from_config(AuthConfig::strict())
56    }
57
58    /// Validate authentication in a request.
59    ///
60    /// Performs validation checks in order:
61    /// 1. Extract token from Authorization header
62    /// 2. Validate token signature (if signing key configured)
63    /// 3. Check token expiry (exp claim)
64    /// 4. Validate issuer/audience claims (if configured)
65    /// 5. Extract required claims (sub)
66    /// 6. Extract optional claims (scope, aud, iss)
67    ///
68    /// # Errors
69    ///
70    /// Returns [`SecurityError::AuthRequired`] if no Authorization header or
71    /// token is missing. Returns [`SecurityError::InvalidToken`] if the token
72    /// signature, expiry, issuer, or audience is invalid. Returns
73    /// [`SecurityError::TokenMissingClaim`] if a required claim is absent.
74    pub fn validate_request(&self, req: &AuthRequest) -> Result<AuthenticatedUser> {
75        // Check 1: Extract token from Authorization header
76        let token = self.extract_token(req)?;
77
78        // Check 2: Validate token (with or without signature verification)
79        if let Some(ref signing_key) = self.config.signing_key {
80            // Use jsonwebtoken crate for proper signature verification
81            self.validate_token_with_signature(&token, signing_key)
82        } else {
83            // Fallback: structure validation only (for testing/backwards compatibility)
84            // WARNING: This is insecure for production use!
85            self.validate_token_structure_only(&token)
86        }
87    }
88
89    /// Validate token with cryptographic signature verification.
90    ///
91    /// This is the secure path used when a signing key is configured.
92    fn validate_token_with_signature(
93        &self,
94        token: &str,
95        signing_key: &SigningKey,
96    ) -> Result<AuthenticatedUser> {
97        // Get the decoding key
98        let decoding_key = signing_key.to_decoding_key()?;
99
100        // Build validation configuration
101        let mut validation = Validation::new(signing_key.algorithm());
102
103        // Configure issuer validation (only validate if configured)
104        if let Some(ref issuer) = self.config.issuer {
105            validation.set_issuer(&[issuer]);
106        }
107        // Note: If issuer is not set, validation.iss is None and won't be validated
108
109        // Configure audience validation.
110        // SECURITY: `validate_aud = true` is the default in jsonwebtoken; we must
111        // NOT override it to `false` when no audience is configured, as that would
112        // silently accept tokens issued for any service (cross-service token replay).
113        // When no audience is pinned, any non-empty `aud` claim is accepted — callers
114        // should set `audience` in config to restrict this further.
115        if let Some(ref audience) = self.config.audience {
116            validation.set_audience(&[audience]);
117        }
118        // `validation.validate_aud` remains `true` (the library default) when no
119        // specific audience is configured.
120
121        // Set clock skew tolerance
122        validation.leeway = self.config.clock_skew_secs;
123
124        // Decode and validate the token
125        let token_data = decode::<JwtClaims>(token, &decoding_key, &validation).map_err(|e| {
126            match e.kind() {
127                jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
128                    // Try to extract the actual expiry time from the token
129                    SecurityError::TokenExpired {
130                        expired_at: Utc::now(), // Approximate - actual time is not accessible
131                    }
132                },
133                jsonwebtoken::errors::ErrorKind::InvalidSignature => {
134                    SecurityError::JwtSignatureInvalid
135                },
136                jsonwebtoken::errors::ErrorKind::InvalidIssuer => {
137                    SecurityError::JwtIssuerMismatch {
138                        expected: self
139                            .config
140                            .issuer
141                            .clone()
142                            .unwrap_or_else(|| "(not configured)".to_string()),
143                    }
144                },
145                jsonwebtoken::errors::ErrorKind::InvalidAudience => {
146                    SecurityError::JwtAudienceMismatch {
147                        expected: self
148                            .config
149                            .audience
150                            .clone()
151                            .unwrap_or_else(|| "(not configured)".to_string()),
152                    }
153                },
154                jsonwebtoken::errors::ErrorKind::InvalidAlgorithm => {
155                    SecurityError::InvalidTokenAlgorithm {
156                        algorithm: format!("{:?}", signing_key.algorithm()),
157                    }
158                },
159                jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claim) => {
160                    SecurityError::TokenMissingClaim {
161                        claim: claim.clone(),
162                    }
163                },
164                _ => SecurityError::InvalidToken,
165            }
166        })?;
167
168        let claims = token_data.claims;
169
170        // Extract scopes (supports multiple formats)
171        let scopes = self.extract_scopes_from_jwt_claims(&claims);
172
173        // Extract user ID (required)
174        let user_id = claims.sub.ok_or(SecurityError::TokenMissingClaim {
175            claim: "sub".to_string(),
176        })?;
177
178        // Extract expiration (required)
179        let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
180            claim: "exp".to_string(),
181        })?;
182
183        let expires_at =
184            DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
185
186        Ok(AuthenticatedUser {
187            user_id,
188            scopes,
189            expires_at,
190            extra_claims: claims.extra,
191        })
192    }
193
194    /// Extract scopes from JWT claims.
195    ///
196    /// Supports multiple formats:
197    /// - `scope`: space-separated string (`OAuth2` standard)
198    /// - `scp`: array of strings (Microsoft)
199    /// - `permissions`: array of strings (Auth0 RBAC)
200    fn extract_scopes_from_jwt_claims(&self, claims: &JwtClaims) -> Vec<String> {
201        // Try space-separated scope string first (most common)
202        if let Some(ref scope) = claims.scope {
203            return scope.split_whitespace().map(String::from).collect();
204        }
205
206        // Try array of scopes (scp claim)
207        if let Some(ref scp) = claims.scp {
208            return scp.clone();
209        }
210
211        // Try permissions array (Auth0 RBAC)
212        if let Some(ref permissions) = claims.permissions {
213            return permissions.clone();
214        }
215
216        Vec::new()
217    }
218
219    /// Validate token structure only (no signature verification).
220    ///
221    /// WARNING: This is insecure and should only be used for testing
222    /// or when signature verification is handled elsewhere.
223    fn validate_token_structure_only(&self, token: &str) -> Result<AuthenticatedUser> {
224        // Validate basic structure
225        self.validate_token_structure(token)?;
226
227        // Parse claims
228        let claims = self.parse_claims(token)?;
229
230        // Extract and validate 'exp' claim (required)
231        let exp = claims.exp.ok_or(SecurityError::TokenMissingClaim {
232            claim: "exp".to_string(),
233        })?;
234
235        // Check expiry
236        let expires_at =
237            DateTime::<Utc>::from_timestamp(exp, 0).ok_or(SecurityError::InvalidToken)?;
238
239        if expires_at <= Utc::now() {
240            return Err(SecurityError::TokenExpired {
241                expired_at: expires_at,
242            });
243        }
244
245        // Extract and validate 'sub' claim (required)
246        let user_id = claims.sub.ok_or(SecurityError::TokenMissingClaim {
247            claim: "sub".to_string(),
248        })?;
249
250        // Extract optional claims
251        let scopes = claims
252            .scope
253            .as_ref()
254            .map(|s| s.split_whitespace().map(String::from).collect())
255            .unwrap_or_default();
256
257        Ok(AuthenticatedUser {
258            user_id,
259            scopes,
260            expires_at,
261            extra_claims: HashMap::new(),
262        })
263    }
264
265    /// Extract token from the authorization header
266    fn extract_token(&self, req: &AuthRequest) -> Result<String> {
267        // If auth is not required and no header present, that's OK
268        if !self.config.required && req.authorization_header.is_none() {
269            return Err(SecurityError::AuthRequired); // Will be handled differently
270        }
271
272        req.extract_bearer_token()
273    }
274
275    /// Validate token structure (basic checks)
276    ///
277    /// A real implementation would validate the signature here.
278    /// For now, we just check basic structure.
279    fn validate_token_structure(&self, token: &str) -> Result<()> {
280        // JWT has 3 parts separated by dots: header.payload.signature
281        let parts: Vec<&str> = token.split('.').collect();
282        if parts.len() != 3 {
283            return Err(SecurityError::InvalidToken);
284        }
285
286        // Check that each part is non-empty
287        if parts.iter().any(|p| p.is_empty()) {
288            return Err(SecurityError::InvalidToken);
289        }
290
291        Ok(())
292    }
293
294    /// Parse JWT claims (simplified, for demo purposes)
295    ///
296    /// In a real implementation, this would decode and validate the JWT signature.
297    /// For testing, we accept a special test token format: "`test:{json_payload`}"
298    fn parse_claims(&self, token: &str) -> Result<TokenClaims> {
299        // Split the token
300        let parts: Vec<&str> = token.split('.').collect();
301        if parts.len() != 3 {
302            return Err(SecurityError::InvalidToken);
303        }
304
305        // For testing, we use a simple format: part1.{json}.part3
306        // where {json} is a base64-like encoded JSON
307        // Since we don't have base64 in core dependencies, we'll try to parse directly
308        let payload_part = parts[1];
309
310        // Try to decode as hex (simpler than base64 and no dependencies)
311        // For test tokens, we'll encode the JSON as hex
312        if let Ok(decoded) = hex::decode(payload_part) {
313            if let Ok(json_str) = std::str::from_utf8(&decoded) {
314                if let Ok(json) = serde_json::from_str::<serde_json::Value>(json_str) {
315                    return Ok(self.extract_claims_from_json(&json));
316                }
317            }
318        }
319
320        // If hex decoding fails, try to parse as UTF-8 directly (for test tokens created inline)
321        if let Ok(json) = serde_json::from_str::<serde_json::Value>(payload_part) {
322            return Ok(self.extract_claims_from_json(&json));
323        }
324
325        Err(SecurityError::InvalidToken)
326    }
327
328    /// Extract claims from parsed JSON
329    fn extract_claims_from_json(&self, json: &serde_json::Value) -> TokenClaims {
330        let sub = json["sub"].as_str().map(String::from);
331        let exp = json["exp"].as_i64();
332        let scope = json["scope"].as_str().map(String::from);
333        let aud = json["aud"]
334            .as_array()
335            .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect());
336        let iss = json["iss"].as_str().map(String::from);
337
338        TokenClaims {
339            sub,
340            exp,
341            scope,
342            aud,
343            iss,
344        }
345    }
346
347    /// Get the underlying configuration
348    #[must_use]
349    pub const fn config(&self) -> &AuthConfig {
350        &self.config
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
357
358    use chrono::Utc;
359    use jsonwebtoken::Algorithm;
360    use zeroize::Zeroizing;
361
362    use super::{
363        super::{
364            config::AuthConfig,
365            signing_key::SigningKey,
366            types::{AuthRequest, AuthenticatedUser},
367        },
368        *,
369    };
370    use crate::security::errors::SecurityError;
371
372    // ============================================================================
373    // Helper Functions
374    // ============================================================================
375
376    /// Create a valid JWT token with specified claims (for testing)
377    ///
378    /// Note: This creates a structurally valid JWT, but doesn't sign it.
379    /// For real use, you'd use a proper JWT library.
380    fn create_test_token(sub: &str, exp_offset_secs: i64, scope: Option<&str>) -> String {
381        let now = chrono::Utc::now().timestamp();
382        let exp = now + exp_offset_secs;
383
384        // Create payload
385        let mut payload = serde_json::json!({
386            "sub": sub,
387            "exp": exp,
388            "iat": now,
389            "aud": ["test-audience"],
390            "iss": "test-issuer"
391        });
392
393        if let Some(s) = scope {
394            payload["scope"] = serde_json::json!(s);
395        }
396
397        // Encode payload as hex for testing
398        let payload_json = payload.to_string();
399        let payload_hex = hex::encode(&payload_json);
400        let signature = "test_signature"; // Not a real signature
401
402        // Format: header.payload_hex.signature
403        format!("header.{payload_hex}.{signature}")
404    }
405
406    // ============================================================================
407    // Check 1: Token Extraction Tests
408    // ============================================================================
409
410    #[test]
411    fn test_bearer_token_extracted_correctly() {
412        let token = "test_token_12345";
413        let req = AuthRequest::new(Some(format!("Bearer {token}")));
414
415        let extracted = req
416            .extract_bearer_token()
417            .unwrap_or_else(|e| panic!("expected bearer token extraction to succeed: {e}"));
418        assert_eq!(extracted, token);
419    }
420
421    #[test]
422    fn test_missing_authorization_header_rejected() {
423        let req = AuthRequest::new(None);
424
425        let result = req.extract_bearer_token();
426        assert!(matches!(result, Err(SecurityError::AuthRequired)));
427    }
428
429    #[test]
430    fn test_invalid_authorization_header_format_rejected() {
431        let req = AuthRequest::new(Some("Basic abc123".to_string()));
432
433        let result = req.extract_bearer_token();
434        assert!(matches!(result, Err(SecurityError::AuthRequired)));
435    }
436
437    #[test]
438    fn test_bearer_prefix_required() {
439        let req = AuthRequest::new(Some("abc123".to_string()));
440
441        let result = req.extract_bearer_token();
442        assert!(matches!(result, Err(SecurityError::AuthRequired)));
443    }
444
445    // ============================================================================
446    // Check 2: Token Structure Validation Tests
447    // ============================================================================
448
449    #[test]
450    fn test_valid_token_structure() {
451        let middleware = AuthMiddleware::permissive();
452        let token = create_test_token("user123", 3600, None);
453
454        let result = middleware.validate_token_structure(&token);
455        result.unwrap_or_else(|e| panic!("expected valid token structure: {e}"));
456    }
457
458    #[test]
459    fn test_token_with_wrong_part_count_rejected() {
460        let middleware = AuthMiddleware::permissive();
461        let token = "header.payload"; // Missing signature
462
463        let result = middleware.validate_token_structure(token);
464        assert!(matches!(result, Err(SecurityError::InvalidToken)));
465    }
466
467    #[test]
468    fn test_token_with_empty_part_rejected() {
469        let middleware = AuthMiddleware::permissive();
470        let token = "header..signature"; // Empty payload
471
472        let result = middleware.validate_token_structure(token);
473        assert!(matches!(result, Err(SecurityError::InvalidToken)));
474    }
475
476    // ============================================================================
477    // Check 3: Token Expiry Validation Tests
478    // ============================================================================
479
480    #[test]
481    fn test_valid_token_not_expired() {
482        let middleware = AuthMiddleware::standard();
483        let token = create_test_token("user123", 3600, None); // 1 hour from now
484        let req = AuthRequest::new(Some(format!("Bearer {token}")));
485
486        let result = middleware.validate_request(&req);
487        result.unwrap_or_else(|e| panic!("expected valid non-expired token to pass: {e}"));
488    }
489
490    #[test]
491    fn test_expired_token_rejected() {
492        let middleware = AuthMiddleware::standard();
493        let token = create_test_token("user123", -3600, None); // 1 hour ago
494        let req = AuthRequest::new(Some(format!("Bearer {token}")));
495
496        let result = middleware.validate_request(&req);
497        assert!(matches!(result, Err(SecurityError::TokenExpired { .. })));
498    }
499
500    /// Sentinel: a token that expired 1 second ago must be rejected.
501    ///
502    /// Kills the `<= → >` and `<= → never-expire` mutations on the expiry check:
503    /// `if expires_at <= Utc::now()`.
504    ///
505    /// Note: testing the exact `expires_at == now` boundary deterministically would
506    /// require clock injection into `AuthMiddleware`, which is not yet supported.
507    /// The ±1-second cases are tested here; the zero-offset case is inherently racy.
508    #[test]
509    fn test_token_expired_one_second_ago_is_rejected() {
510        let middleware = AuthMiddleware::standard();
511        let token = create_test_token("user123", -1, None); // expired 1 second ago
512        let req = AuthRequest::new(Some(format!("Bearer {token}")));
513
514        assert!(
515            matches!(middleware.validate_request(&req), Err(SecurityError::TokenExpired { .. })),
516            "token expired 1s ago must be rejected"
517        );
518    }
519
520    /// Sentinel: a token expiring 60 seconds from now must be accepted.
521    ///
522    /// Complements `test_token_expired_one_second_ago_is_rejected` to pin the valid
523    /// side of the expiry boundary.
524    #[test]
525    fn test_token_expiring_soon_is_accepted() {
526        let middleware = AuthMiddleware::standard();
527        let token = create_test_token("user123", 60, None); // expires in 60 seconds
528        let req = AuthRequest::new(Some(format!("Bearer {token}")));
529
530        assert!(
531            middleware.validate_request(&req).is_ok(),
532            "token expiring in 60s must be accepted"
533        );
534    }
535
536    // ============================================================================
537    // Check 4: Required Claims Validation Tests
538    // ============================================================================
539
540    #[test]
541    fn test_missing_sub_claim_rejected() {
542        let middleware = AuthMiddleware::standard();
543
544        // Create token without 'sub' claim
545        let now = chrono::Utc::now().timestamp();
546        let payload = serde_json::json!({
547            "exp": now + 3600,
548            "iat": now
549        });
550
551        let payload_hex = hex::encode(payload.to_string());
552        let token = format!("header.{payload_hex}.signature");
553
554        let req = AuthRequest::new(Some(format!("Bearer {token}")));
555        let result = middleware.validate_request(&req);
556
557        assert!(matches!(
558            result,
559            Err(SecurityError::TokenMissingClaim { claim })
560            if claim == "sub"
561        ));
562    }
563
564    #[test]
565    fn test_missing_exp_claim_rejected() {
566        let middleware = AuthMiddleware::standard();
567
568        // Create token without 'exp' claim
569        let payload = serde_json::json!({
570            "sub": "user123",
571            "iat": chrono::Utc::now().timestamp()
572        });
573
574        let payload_hex = hex::encode(payload.to_string());
575        let token = format!("header.{payload_hex}.signature");
576
577        let req = AuthRequest::new(Some(format!("Bearer {token}")));
578        let result = middleware.validate_request(&req);
579
580        assert!(matches!(
581            result,
582            Err(SecurityError::TokenMissingClaim { claim })
583            if claim == "exp"
584        ));
585    }
586
587    // ============================================================================
588    // Check 5: User Info Extraction Tests
589    // ============================================================================
590
591    #[test]
592    fn test_user_id_extracted_from_token() {
593        let middleware = AuthMiddleware::standard();
594        let token = create_test_token("user_12345", 3600, None);
595        let req = AuthRequest::new(Some(format!("Bearer {token}")));
596
597        let user = middleware
598            .validate_request(&req)
599            .unwrap_or_else(|e| panic!("expected user_id extraction to succeed: {e}"));
600        assert_eq!(user.user_id, "user_12345");
601    }
602
603    #[test]
604    fn test_scopes_extracted_from_token() {
605        let middleware = AuthMiddleware::standard();
606        let token = create_test_token("user123", 3600, Some("read write admin"));
607        let req = AuthRequest::new(Some(format!("Bearer {token}")));
608
609        let user = middleware
610            .validate_request(&req)
611            .unwrap_or_else(|e| panic!("expected scope extraction to succeed: {e}"));
612        assert_eq!(user.scopes, vec!["read", "write", "admin"]);
613    }
614
615    #[test]
616    fn test_empty_scopes_when_scope_claim_absent() {
617        let middleware = AuthMiddleware::standard();
618        let token = create_test_token("user123", 3600, None);
619        let req = AuthRequest::new(Some(format!("Bearer {token}")));
620
621        let user = middleware
622            .validate_request(&req)
623            .unwrap_or_else(|e| panic!("expected token without scopes to be valid: {e}"));
624        assert!(user.scopes.is_empty(), "expected empty scopes, got: {:?}", user.scopes);
625    }
626
627    #[test]
628    fn test_expires_at_extracted_correctly() {
629        let middleware = AuthMiddleware::standard();
630        let offset_secs = 7200; // 2 hours
631
632        let token = create_test_token("user123", offset_secs, None);
633        let req = AuthRequest::new(Some(format!("Bearer {token}")));
634
635        let user = middleware
636            .validate_request(&req)
637            .unwrap_or_else(|e| panic!("expected expiry extraction to succeed: {e}"));
638        let now = Utc::now();
639        let diff = (user.expires_at - now).num_seconds();
640
641        // Should be approximately offset_secs (within 5 seconds due to processing)
642        assert!((offset_secs - 5..=offset_secs + 5).contains(&diff));
643    }
644
645    // ============================================================================
646    // Configuration Tests
647    // ============================================================================
648
649    #[test]
650    fn test_permissive_config() {
651        let config = AuthConfig::permissive();
652        assert!(!config.required);
653        assert_eq!(config.token_expiry_secs, 3600);
654    }
655
656    #[test]
657    fn test_standard_config() {
658        let config = AuthConfig::standard();
659        assert!(config.required);
660        assert_eq!(config.token_expiry_secs, 3600);
661    }
662
663    #[test]
664    fn test_strict_config() {
665        let config = AuthConfig::strict();
666        assert!(config.required);
667        assert_eq!(config.token_expiry_secs, 1800);
668    }
669
670    #[test]
671    fn test_middleware_helpers() {
672        let permissive = AuthMiddleware::permissive();
673        assert!(!permissive.config().required);
674
675        let standard = AuthMiddleware::standard();
676        assert!(standard.config().required);
677
678        let strict = AuthMiddleware::strict();
679        assert!(strict.config().required);
680    }
681
682    // ============================================================================
683    // AuthenticatedUser Tests
684    // ============================================================================
685
686    #[test]
687    fn test_user_has_scope() {
688        let user = AuthenticatedUser {
689            user_id:      "user123".to_string(),
690            scopes:       vec!["read".to_string(), "write".to_string()],
691            expires_at:   Utc::now() + chrono::Duration::hours(1),
692            extra_claims: HashMap::new(),
693        };
694
695        assert!(user.has_scope("read"));
696        assert!(user.has_scope("write"));
697        assert!(!user.has_scope("admin"));
698    }
699
700    #[test]
701    fn test_user_is_not_expired() {
702        let user = AuthenticatedUser {
703            user_id:      "user123".to_string(),
704            scopes:       vec![],
705            expires_at:   Utc::now() + chrono::Duration::hours(1),
706            extra_claims: HashMap::new(),
707        };
708
709        assert!(!user.is_expired());
710    }
711
712    #[test]
713    fn test_user_is_expired() {
714        let user = AuthenticatedUser {
715            user_id:      "user123".to_string(),
716            scopes:       vec![],
717            expires_at:   Utc::now() - chrono::Duration::hours(1),
718            extra_claims: HashMap::new(),
719        };
720
721        assert!(user.is_expired());
722    }
723
724    #[test]
725    fn test_user_ttl_calculation() {
726        let now = Utc::now();
727        let expires_at = now + chrono::Duration::hours(2);
728        let user = AuthenticatedUser {
729            user_id:      "user123".to_string(),
730            scopes:       vec![],
731            expires_at,
732            extra_claims: HashMap::new(),
733        };
734
735        let ttl = user.ttl_secs();
736        // Should be approximately 7200 seconds (2 hours)
737        assert!((7195..=7205).contains(&ttl));
738    }
739
740    #[test]
741    fn test_user_display() {
742        let user = AuthenticatedUser {
743            user_id:      "user123".to_string(),
744            scopes:       vec![],
745            expires_at:   Utc::now() + chrono::Duration::hours(1),
746            extra_claims: HashMap::new(),
747        };
748
749        let display_str = user.to_string();
750        assert!(display_str.contains("user123"));
751        assert!(display_str.contains("UTC"));
752    }
753
754    // ============================================================================
755    // Error Message Tests
756    // ============================================================================
757
758    #[test]
759    fn test_error_messages_clear_and_actionable() {
760        let middleware = AuthMiddleware::standard();
761
762        // Test missing header error
763        let req = AuthRequest::new(None);
764        let result = middleware.validate_request(&req);
765        assert!(matches!(result, Err(SecurityError::AuthRequired)));
766
767        // Test invalid format error
768        let req = AuthRequest::new(Some("Basic xyz".to_string()));
769        let result = middleware.validate_request(&req);
770        assert!(matches!(result, Err(SecurityError::AuthRequired)));
771    }
772
773    // ============================================================================
774    // Edge Cases
775    // ============================================================================
776
777    #[test]
778    fn test_auth_not_required_allows_missing_token() {
779        // When auth is NOT required, missing token should still go through extraction
780        let middleware = AuthMiddleware::permissive(); // required = false
781        let req = AuthRequest::new(None);
782
783        let result = middleware.validate_request(&req);
784        // Should fail at extraction, not because auth is optional
785        assert!(matches!(result, Err(SecurityError::AuthRequired)));
786    }
787
788    #[test]
789    fn test_whitespace_in_scopes_handled() {
790        let middleware = AuthMiddleware::standard();
791        let token = create_test_token("user123", 3600, Some("  read   write  admin  "));
792        let req = AuthRequest::new(Some(format!("Bearer {token}")));
793
794        let user = middleware
795            .validate_request(&req)
796            .unwrap_or_else(|e| panic!("expected whitespace-heavy scopes to parse: {e}"));
797        // split_whitespace handles multiple spaces correctly
798        assert_eq!(user.scopes.len(), 3);
799    }
800
801    #[test]
802    fn test_single_scope_parsed_correctly() {
803        let middleware = AuthMiddleware::standard();
804        let token = create_test_token("user123", 3600, Some("read"));
805        let req = AuthRequest::new(Some(format!("Bearer {token}")));
806
807        let user = middleware
808            .validate_request(&req)
809            .unwrap_or_else(|e| panic!("expected single scope to parse: {e}"));
810        assert_eq!(user.scopes, vec!["read"]);
811    }
812
813    // ============================================================================
814    // JWT Signature Verification Tests (Issue #225)
815    // ============================================================================
816
817    /// Helper to create a properly signed HS256 JWT token
818    #[allow(clippy::items_after_statements)] // Reason: test helper structs defined near point of use for readability
819    fn create_signed_hs256_token(
820        sub: &str,
821        exp_offset_secs: i64,
822        scope: Option<&str>,
823        secret: &str,
824    ) -> String {
825        use jsonwebtoken::{EncodingKey, Header, encode};
826
827        let now = chrono::Utc::now().timestamp();
828        let exp = now + exp_offset_secs;
829
830        #[derive(serde::Serialize)]
831        struct Claims {
832            sub:   String,
833            exp:   i64,
834            iat:   i64,
835            #[serde(skip_serializing_if = "Option::is_none")]
836            scope: Option<String>,
837        }
838
839        let claims = Claims {
840            sub: sub.to_string(),
841            exp,
842            iat: now,
843            scope: scope.map(String::from),
844        };
845
846        encode(
847            &Header::default(), // HS256
848            &claims,
849            &EncodingKey::from_secret(secret.as_bytes()),
850        )
851        .expect("Failed to create test token")
852    }
853
854    #[test]
855    fn test_hs256_signature_verification_valid_token() {
856        let secret = "super-secret-key-for-testing-only";
857        let config = AuthConfig::with_hs256(secret);
858        let middleware = AuthMiddleware::from_config(config);
859
860        let token = create_signed_hs256_token("user123", 3600, Some("read write"), secret);
861        let req = AuthRequest::new(Some(format!("Bearer {token}")));
862
863        let result = middleware.validate_request(&req);
864        assert!(result.is_ok(), "Expected valid token, got: {:?}", result);
865
866        let user = result.unwrap();
867        assert_eq!(user.user_id, "user123");
868        assert_eq!(user.scopes, vec!["read", "write"]);
869    }
870
871    #[test]
872    fn test_hs256_signature_verification_wrong_secret_rejected() {
873        let signing_secret = "correct-secret";
874        let wrong_secret = "wrong-secret";
875
876        let config = AuthConfig::with_hs256(signing_secret);
877        let middleware = AuthMiddleware::from_config(config);
878
879        // Token signed with wrong secret
880        let token = create_signed_hs256_token("user123", 3600, None, wrong_secret);
881        let req = AuthRequest::new(Some(format!("Bearer {token}")));
882
883        let result = middleware.validate_request(&req);
884        assert!(
885            matches!(result, Err(SecurityError::JwtSignatureInvalid)),
886            "Expected JwtSignatureInvalid for wrong signature, got: {:?}",
887            result
888        );
889    }
890
891    #[test]
892    fn test_hs256_expired_token_rejected() {
893        let secret = "test-secret";
894        let config = AuthConfig::with_hs256(secret);
895        let middleware = AuthMiddleware::from_config(config);
896
897        // Token expired 1 hour ago
898        let token = create_signed_hs256_token("user123", -3600, None, secret);
899        let req = AuthRequest::new(Some(format!("Bearer {token}")));
900
901        let result = middleware.validate_request(&req);
902        assert!(
903            matches!(result, Err(SecurityError::TokenExpired { .. })),
904            "Expected TokenExpired, got: {:?}",
905            result
906        );
907    }
908
909    #[test]
910    #[allow(clippy::items_after_statements)] // Reason: test helper structs defined near point of use for readability
911    fn test_hs256_with_issuer_validation() {
912        use jsonwebtoken::{EncodingKey, Header, encode};
913
914        #[derive(serde::Serialize)]
915        struct ClaimsWithIss {
916            sub: String,
917            exp: i64,
918            iss: String,
919        }
920
921        let secret = "test-secret";
922        let config = AuthConfig::with_hs256(secret).with_issuer("https://auth.example.com");
923        let middleware = AuthMiddleware::from_config(config);
924
925        // Create token with matching issuer
926        let now = chrono::Utc::now().timestamp();
927        let claims = ClaimsWithIss {
928            sub: "user123".to_string(),
929            exp: now + 3600,
930            iss: "https://auth.example.com".to_string(),
931        };
932
933        let token =
934            encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
935                .unwrap();
936
937        let req = AuthRequest::new(Some(format!("Bearer {token}")));
938        let result = middleware.validate_request(&req);
939        assert!(result.is_ok(), "Expected valid token with issuer, got: {:?}", result);
940    }
941
942    #[test]
943    #[allow(clippy::items_after_statements)] // Reason: test helper structs defined near point of use for readability
944    fn test_hs256_with_wrong_issuer_rejected() {
945        use jsonwebtoken::{EncodingKey, Header, encode};
946
947        #[derive(serde::Serialize)]
948        struct ClaimsWithIss {
949            sub: String,
950            exp: i64,
951            iss: String,
952        }
953
954        let secret = "test-secret";
955        let config = AuthConfig::with_hs256(secret).with_issuer("https://auth.example.com");
956        let middleware = AuthMiddleware::from_config(config);
957
958        // Create token with wrong issuer
959        let now = chrono::Utc::now().timestamp();
960        let claims = ClaimsWithIss {
961            sub: "user123".to_string(),
962            exp: now + 3600,
963            iss: "https://wrong-issuer.com".to_string(),
964        };
965
966        let token =
967            encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
968                .unwrap();
969
970        let req = AuthRequest::new(Some(format!("Bearer {token}")));
971        let result = middleware.validate_request(&req);
972        assert!(
973            matches!(result, Err(SecurityError::JwtIssuerMismatch { .. })),
974            "Expected JwtIssuerMismatch for wrong issuer, got: {:?}",
975            result
976        );
977    }
978
979    #[test]
980    #[allow(clippy::items_after_statements)] // Reason: test helper structs defined near point of use for readability
981    fn test_jwt_issuer_mismatch_error_contains_expected_issuer_and_word_issuer() {
982        use jsonwebtoken::{EncodingKey, Header, encode};
983
984        #[derive(serde::Serialize)]
985        struct ClaimsWithIss {
986            sub: String,
987            exp: i64,
988            iss: String,
989        }
990
991        let secret = "test-secret";
992        let expected_issuer = "https://auth.example.com";
993        let config = AuthConfig::with_hs256(secret).with_issuer(expected_issuer);
994        let middleware = AuthMiddleware::from_config(config);
995
996        // Token signed with the right secret but wrong issuer.
997        let now = chrono::Utc::now().timestamp();
998        let claims = ClaimsWithIss {
999            sub: "user123".to_string(),
1000            exp: now + 3600,
1001            iss: "https://wrong-issuer.com".to_string(),
1002        };
1003
1004        let token =
1005            encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
1006                .unwrap();
1007
1008        let req = AuthRequest::new(Some(format!("Bearer {token}")));
1009        let result = middleware.validate_request(&req);
1010        let err = result.expect_err("expected error for wrong issuer");
1011        let msg = err.to_string();
1012
1013        assert!(
1014            msg.contains(expected_issuer),
1015            "error message must contain expected issuer '{expected_issuer}': {msg}"
1016        );
1017        assert!(msg.contains("issuer"), "error message must contain 'issuer': {msg}");
1018    }
1019
1020    #[test]
1021    #[allow(clippy::items_after_statements)] // Reason: test helper structs defined near point of use for readability
1022    fn test_hs256_with_audience_validation() {
1023        use jsonwebtoken::{EncodingKey, Header, encode};
1024
1025        #[derive(serde::Serialize)]
1026        struct ClaimsWithAud {
1027            sub: String,
1028            exp: i64,
1029            aud: String,
1030        }
1031
1032        let secret = "test-secret";
1033        let config = AuthConfig::with_hs256(secret).with_audience("my-api");
1034        let middleware = AuthMiddleware::from_config(config);
1035
1036        // Create token with matching audience
1037        let now = chrono::Utc::now().timestamp();
1038        let claims = ClaimsWithAud {
1039            sub: "user123".to_string(),
1040            exp: now + 3600,
1041            aud: "my-api".to_string(),
1042        };
1043
1044        let token =
1045            encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
1046                .unwrap();
1047
1048        let req = AuthRequest::new(Some(format!("Bearer {token}")));
1049        let result = middleware.validate_request(&req);
1050        assert!(result.is_ok(), "Expected valid token with audience, got: {:?}", result);
1051    }
1052
1053    #[test]
1054    fn test_signing_key_algorithm_detection() {
1055        let hs256 = SigningKey::hs256("secret");
1056        assert!(matches!(hs256.algorithm(), Algorithm::HS256));
1057
1058        let hs384 = SigningKey::Hs384(Zeroizing::new(b"secret".to_vec()));
1059        assert!(matches!(hs384.algorithm(), Algorithm::HS384));
1060
1061        let hs512 = SigningKey::Hs512(Zeroizing::new(b"secret".to_vec()));
1062        assert!(matches!(hs512.algorithm(), Algorithm::HS512));
1063
1064        let rs256_pem = SigningKey::rs256_pem("fake-pem");
1065        assert!(matches!(rs256_pem.algorithm(), Algorithm::RS256));
1066
1067        let rs256_comp = SigningKey::rs256_components("n", "e");
1068        assert!(matches!(rs256_comp.algorithm(), Algorithm::RS256));
1069    }
1070
1071    #[test]
1072    fn test_config_has_signing_key() {
1073        let config_without = AuthConfig::standard();
1074        assert!(!config_without.has_signing_key());
1075
1076        let config_with = AuthConfig::with_hs256("secret");
1077        assert!(config_with.has_signing_key());
1078    }
1079
1080    #[test]
1081    fn test_config_builder_pattern() {
1082        let config = AuthConfig::with_hs256("secret")
1083            .with_issuer("https://auth.example.com")
1084            .with_audience("my-api");
1085
1086        assert!(config.has_signing_key());
1087        assert_eq!(config.issuer, Some("https://auth.example.com".to_string()));
1088        assert_eq!(config.audience, Some("my-api".to_string()));
1089    }
1090
1091    #[test]
1092    fn test_malformed_token_rejected_with_signature_verification() {
1093        let config = AuthConfig::with_hs256("secret");
1094        let middleware = AuthMiddleware::from_config(config);
1095
1096        // Not a valid JWT at all
1097        let req = AuthRequest::new(Some("Bearer not-a-jwt".to_string()));
1098        let result = middleware.validate_request(&req);
1099        assert!(
1100            matches!(result, Err(SecurityError::InvalidToken)),
1101            "Expected InvalidToken for malformed JWT, got: {:?}",
1102            result
1103        );
1104    }
1105
1106    #[test]
1107    fn test_tampered_payload_rejected() {
1108        let secret = "test-secret";
1109        let config = AuthConfig::with_hs256(secret);
1110        let middleware = AuthMiddleware::from_config(config);
1111
1112        // Create a valid token
1113        let token = create_signed_hs256_token("user123", 3600, None, secret);
1114
1115        // Tamper with the payload (change middle part)
1116        let parts: Vec<&str> = token.split('.').collect();
1117        let tampered_token = format!("{}.dGFtcGVyZWQ.{}", parts[0], parts[2]);
1118
1119        let req = AuthRequest::new(Some(format!("Bearer {tampered_token}")));
1120        let result = middleware.validate_request(&req);
1121        assert!(
1122            matches!(result, Err(SecurityError::JwtSignatureInvalid)),
1123            "Expected JwtSignatureInvalid for tampered payload, got: {:?}",
1124            result
1125        );
1126    }
1127
1128    #[test]
1129    fn test_clock_skew_tolerance() {
1130        let secret = "test-secret";
1131        let mut config = AuthConfig::with_hs256(secret);
1132        config.clock_skew_secs = 120; // 2 minutes tolerance
1133        let middleware = AuthMiddleware::from_config(config);
1134
1135        // Token that expired 30 seconds ago (within 2 minute tolerance)
1136        let token = create_signed_hs256_token("user123", -30, None, secret);
1137        let req = AuthRequest::new(Some(format!("Bearer {token}")));
1138
1139        let result = middleware.validate_request(&req);
1140        // Should still be valid due to clock skew tolerance
1141        assert!(result.is_ok(), "Expected valid token within clock skew, got: {:?}", result);
1142    }
1143}