Skip to main content

forge_runtime/gateway/
auth.rs

1use std::sync::Arc;
2
3use axum::{
4    body::Body,
5    extract::{Request, State},
6    http::{StatusCode, header},
7    middleware::Next,
8    response::{IntoResponse, Json, Response},
9};
10use forge_core::auth::Claims;
11use forge_core::config::JwtAlgorithm as CoreJwtAlgorithm;
12use forge_core::function::AuthContext;
13use jsonwebtoken::{Algorithm, DecodingKey, Validation, dangerous, decode, encode};
14use tracing::debug;
15
16use super::jwks::JwksClient;
17
18/// Authentication configuration for the runtime.
19#[derive(Debug, Clone)]
20pub struct AuthConfig {
21    /// JWT secret for HMAC algorithms (HS256, HS384, HS512).
22    pub jwt_secret: Option<String>,
23    /// JWT algorithm.
24    pub algorithm: JwtAlgorithm,
25    /// JWKS client for RSA algorithms.
26    pub jwks_client: Option<Arc<JwksClient>>,
27    /// Expected token issuer (iss claim).
28    pub issuer: Option<String>,
29    /// Expected audience (aud claim).
30    pub audience: Option<String>,
31    /// Skip signature verification (DEV MODE ONLY - NEVER USE IN PRODUCTION).
32    /// This field is intentionally not public. Use `dev_mode()` constructor which
33    /// includes a runtime guard against production use.
34    pub(crate) skip_verification: bool,
35}
36
37impl Default for AuthConfig {
38    fn default() -> Self {
39        Self {
40            jwt_secret: None,
41            algorithm: JwtAlgorithm::HS256,
42            jwks_client: None,
43            issuer: None,
44            audience: None,
45            skip_verification: false,
46        }
47    }
48}
49
50impl AuthConfig {
51    /// Create auth config from forge core config.
52    pub fn from_forge_config(
53        config: &forge_core::config::AuthConfig,
54    ) -> Result<Self, super::jwks::JwksError> {
55        let algorithm = JwtAlgorithm::from(config.jwt_algorithm);
56
57        let jwks_client = config
58            .jwks_url
59            .as_ref()
60            .map(|url| JwksClient::new(url.clone(), config.jwks_cache_ttl_secs).map(Arc::new))
61            .transpose()?;
62
63        Ok(Self {
64            jwt_secret: config.jwt_secret.clone(),
65            algorithm,
66            jwks_client,
67            issuer: config.jwt_issuer.clone(),
68            audience: config.jwt_audience.clone(),
69            skip_verification: false,
70        })
71    }
72
73    /// Create a new auth config with the given HMAC secret.
74    pub fn with_secret(secret: impl Into<String>) -> Self {
75        Self {
76            jwt_secret: Some(secret.into()),
77            ..Default::default()
78        }
79    }
80
81    /// Create a dev mode config that skips signature verification.
82    /// WARNING: Only use this for development and testing!
83    ///
84    /// Returns a production-safe config (verification enabled, no secret) if
85    /// `FORGE_ENV` is set to `"production"`, preventing accidental misuse.
86    pub fn dev_mode() -> Self {
87        if std::env::var("FORGE_ENV")
88            .map(|v| v.eq_ignore_ascii_case("production"))
89            .unwrap_or(false)
90        {
91            tracing::error!(
92                "AuthConfig::dev_mode() called with FORGE_ENV=production. \
93                 Returning default config with verification enabled."
94            );
95            return Self::default();
96        }
97        Self {
98            jwt_secret: None,
99            algorithm: JwtAlgorithm::HS256,
100            jwks_client: None,
101            issuer: None,
102            audience: None,
103            skip_verification: true,
104        }
105    }
106
107    /// Check if this config uses HMAC (symmetric) algorithms.
108    pub fn is_hmac(&self) -> bool {
109        matches!(
110            self.algorithm,
111            JwtAlgorithm::HS256 | JwtAlgorithm::HS384 | JwtAlgorithm::HS512
112        )
113    }
114
115    /// Check if this config uses RSA (asymmetric) algorithms.
116    pub fn is_rsa(&self) -> bool {
117        matches!(
118            self.algorithm,
119            JwtAlgorithm::RS256 | JwtAlgorithm::RS384 | JwtAlgorithm::RS512
120        )
121    }
122}
123
124/// Supported JWT algorithms.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
126pub enum JwtAlgorithm {
127    #[default]
128    HS256,
129    HS384,
130    HS512,
131    RS256,
132    RS384,
133    RS512,
134}
135
136impl From<JwtAlgorithm> for Algorithm {
137    fn from(alg: JwtAlgorithm) -> Self {
138        match alg {
139            JwtAlgorithm::HS256 => Algorithm::HS256,
140            JwtAlgorithm::HS384 => Algorithm::HS384,
141            JwtAlgorithm::HS512 => Algorithm::HS512,
142            JwtAlgorithm::RS256 => Algorithm::RS256,
143            JwtAlgorithm::RS384 => Algorithm::RS384,
144            JwtAlgorithm::RS512 => Algorithm::RS512,
145        }
146    }
147}
148
149impl From<CoreJwtAlgorithm> for JwtAlgorithm {
150    fn from(alg: CoreJwtAlgorithm) -> Self {
151        match alg {
152            CoreJwtAlgorithm::HS256 => JwtAlgorithm::HS256,
153            CoreJwtAlgorithm::HS384 => JwtAlgorithm::HS384,
154            CoreJwtAlgorithm::HS512 => JwtAlgorithm::HS512,
155            CoreJwtAlgorithm::RS256 => JwtAlgorithm::RS256,
156            CoreJwtAlgorithm::RS384 => JwtAlgorithm::RS384,
157            CoreJwtAlgorithm::RS512 => JwtAlgorithm::RS512,
158        }
159    }
160}
161
162/// Token issuer for HMAC-based JWT signing.
163///
164/// Created from the auth config when an HMAC algorithm is configured.
165/// Passed into MutationContext so handlers can call `ctx.issue_token()`.
166#[derive(Clone)]
167pub struct HmacTokenIssuer {
168    secret: String,
169    algorithm: Algorithm,
170}
171
172impl HmacTokenIssuer {
173    /// Create a token issuer from auth config, if HMAC auth is configured.
174    pub fn from_config(config: &AuthConfig) -> Option<Self> {
175        if !config.is_hmac() {
176            return None;
177        }
178        let secret = config.jwt_secret.as_ref()?.clone();
179        if secret.is_empty() {
180            return None;
181        }
182        if secret.len() < 32 {
183            tracing::warn!(
184                secret_len = secret.len(),
185                "JWT secret is shorter than 32 bytes. This weakens HMAC security \
186                 and may allow brute-force attacks. Use a cryptographically random \
187                 secret of at least 32 bytes (e.g. `openssl rand -base64 32`)."
188            );
189        }
190        Some(Self {
191            secret,
192            algorithm: config.algorithm.into(),
193        })
194    }
195}
196
197impl forge_core::TokenIssuer for HmacTokenIssuer {
198    fn sign(&self, claims: &Claims) -> forge_core::Result<String> {
199        let header = jsonwebtoken::Header::new(self.algorithm);
200        encode(
201            &header,
202            claims,
203            &jsonwebtoken::EncodingKey::from_secret(self.secret.as_bytes()),
204        )
205        .map_err(|e| forge_core::ForgeError::Internal(format!("token signing error: {e}")))
206    }
207}
208
209/// Authentication middleware.
210#[derive(Clone)]
211pub struct AuthMiddleware {
212    config: Arc<AuthConfig>,
213    /// Pre-computed HMAC decoding key (for performance).
214    hmac_key: Option<DecodingKey>,
215}
216
217impl std::fmt::Debug for AuthMiddleware {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("AuthMiddleware")
220            .field("config", &self.config)
221            .field("hmac_key", &self.hmac_key.is_some())
222            .finish()
223    }
224}
225
226impl AuthMiddleware {
227    /// Create a new auth middleware.
228    pub fn new(config: AuthConfig) -> Self {
229        if config.skip_verification {
230            tracing::warn!("JWT signature verification is DISABLED. Do not use in production.");
231        }
232
233        // Pre-compute HMAC key if using HMAC algorithm
234        let hmac_key = if config.skip_verification {
235            None
236        } else if config.is_hmac() {
237            config
238                .jwt_secret
239                .as_ref()
240                .filter(|s| !s.is_empty())
241                .map(|secret| DecodingKey::from_secret(secret.as_bytes()))
242        } else {
243            None
244        };
245
246        Self {
247            config: Arc::new(config),
248            hmac_key,
249        }
250    }
251
252    /// Create a middleware that allows all requests (development mode).
253    /// WARNING: This skips signature verification! Never use in production.
254    pub fn permissive() -> Self {
255        Self::new(AuthConfig::dev_mode())
256    }
257
258    /// Get the config.
259    pub fn config(&self) -> &AuthConfig {
260        &self.config
261    }
262
263    /// Validate a JWT token and extract claims.
264    pub async fn validate_token_async(&self, token: &str) -> Result<Claims, AuthError> {
265        if self.config.skip_verification {
266            return self.decode_without_verification(token);
267        }
268
269        if self.config.is_hmac() {
270            self.validate_hmac(token)
271        } else {
272            self.validate_rsa(token).await
273        }
274    }
275
276    /// Validate HMAC-signed token.
277    fn validate_hmac(&self, token: &str) -> Result<Claims, AuthError> {
278        let key = self.hmac_key.as_ref().ok_or_else(|| {
279            AuthError::InvalidToken("JWT secret not configured for HMAC".to_string())
280        })?;
281
282        self.decode_and_validate(token, key)
283    }
284
285    /// Validate RSA-signed token using JWKS.
286    async fn validate_rsa(&self, token: &str) -> Result<Claims, AuthError> {
287        let jwks = self.config.jwks_client.as_ref().ok_or_else(|| {
288            AuthError::InvalidToken("JWKS URL not configured for RSA".to_string())
289        })?;
290
291        // Extract key ID from token header
292        let header = jsonwebtoken::decode_header(token)
293            .map_err(|e| AuthError::InvalidToken(format!("Invalid token header: {}", e)))?;
294
295        debug!(kid = ?header.kid, alg = ?header.alg, "Validating RSA token");
296
297        // Get key from JWKS
298        let key = if let Some(kid) = header.kid {
299            jwks.get_key(&kid).await.map_err(|e| {
300                AuthError::InvalidToken(format!("Failed to get key '{}': {}", kid, e))
301            })?
302        } else {
303            jwks.get_any_key()
304                .await
305                .map_err(|e| AuthError::InvalidToken(format!("Failed to get JWKS key: {}", e)))?
306        };
307
308        self.decode_and_validate(token, &key)
309    }
310
311    /// Decode and validate token with the given key.
312    fn decode_and_validate(&self, token: &str, key: &DecodingKey) -> Result<Claims, AuthError> {
313        let mut validation = Validation::new(self.config.algorithm.into());
314
315        // Configure validation
316        validation.validate_exp = true;
317        validation.validate_nbf = true;
318        validation.leeway = 60; // 60 seconds clock skew tolerance
319
320        // Require exp and sub claims
321        validation.set_required_spec_claims(&["exp", "sub"]);
322
323        // Validate issuer if configured
324        if let Some(ref issuer) = self.config.issuer {
325            validation.set_issuer(&[issuer]);
326        }
327
328        // Validate audience if configured
329        if let Some(ref audience) = self.config.audience {
330            validation.set_audience(&[audience]);
331        } else {
332            validation.validate_aud = false;
333        }
334
335        let token_data =
336            decode::<Claims>(token, key, &validation).map_err(|e| self.map_jwt_error(e))?;
337
338        Ok(token_data.claims)
339    }
340
341    /// Map jsonwebtoken errors to AuthError.
342    fn map_jwt_error(&self, e: jsonwebtoken::errors::Error) -> AuthError {
343        match e.kind() {
344            jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::TokenExpired,
345            jsonwebtoken::errors::ErrorKind::InvalidSignature => {
346                AuthError::InvalidToken("Invalid signature".to_string())
347            }
348            jsonwebtoken::errors::ErrorKind::InvalidToken => {
349                AuthError::InvalidToken("Invalid token format".to_string())
350            }
351            jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(claim) => {
352                AuthError::InvalidToken(format!("Missing required claim: {}", claim))
353            }
354            jsonwebtoken::errors::ErrorKind::InvalidIssuer => {
355                AuthError::InvalidToken("Invalid issuer".to_string())
356            }
357            jsonwebtoken::errors::ErrorKind::InvalidAudience => {
358                AuthError::InvalidToken("Invalid audience".to_string())
359            }
360            _ => AuthError::InvalidToken(e.to_string()),
361        }
362    }
363
364    /// Decode JWT token without signature verification (DEV MODE ONLY).
365    fn decode_without_verification(&self, token: &str) -> Result<Claims, AuthError> {
366        let token_data =
367            dangerous::insecure_decode::<Claims>(token).map_err(|e| match e.kind() {
368                jsonwebtoken::errors::ErrorKind::InvalidToken => {
369                    AuthError::InvalidToken("Invalid token format".to_string())
370                }
371                _ => AuthError::InvalidToken(e.to_string()),
372            })?;
373
374        // Still check expiration in dev mode
375        if token_data.claims.is_expired() {
376            return Err(AuthError::TokenExpired);
377        }
378
379        Ok(token_data.claims)
380    }
381}
382
383/// Authentication errors.
384#[derive(Debug, Clone, thiserror::Error)]
385pub enum AuthError {
386    #[error("Missing authorization header")]
387    MissingHeader,
388    #[error("Invalid authorization header format")]
389    InvalidHeader,
390    #[error("Invalid token: {0}")]
391    InvalidToken(String),
392    #[error("Token expired")]
393    TokenExpired,
394}
395
396/// Extract token from request headers.
397pub fn extract_token(req: &Request<Body>) -> Result<Option<String>, AuthError> {
398    let Some(header_value) = req.headers().get(axum::http::header::AUTHORIZATION) else {
399        return Ok(None);
400    };
401
402    let header = header_value
403        .to_str()
404        .map_err(|_| AuthError::InvalidHeader)?;
405    let token = header
406        .strip_prefix("Bearer ")
407        .ok_or(AuthError::InvalidHeader)?
408        .trim();
409
410    if token.is_empty() {
411        return Err(AuthError::InvalidHeader);
412    }
413
414    Ok(Some(token.to_string()))
415}
416
417/// Extract auth context from token (async, supports both HMAC and RSA/JWKS).
418pub async fn extract_auth_context_async(
419    token: Option<String>,
420    middleware: &AuthMiddleware,
421) -> Result<AuthContext, AuthError> {
422    match token {
423        Some(token) => middleware
424            .validate_token_async(&token)
425            .await
426            .map(build_auth_context_from_claims),
427        None => Ok(AuthContext::unauthenticated()),
428    }
429}
430
431/// Build auth context from validated claims.
432///
433/// This handles both UUID and non-UUID subjects properly:
434/// - UUID subjects: uses `authenticated()` with the parsed UUID
435/// - Non-UUID subjects: uses `authenticated_without_uuid()` and stores raw subject in claims
436pub fn build_auth_context_from_claims(claims: Claims) -> AuthContext {
437    // Try to parse subject as UUID first (before moving claims)
438    let user_id = claims.user_id();
439
440    // Build custom claims with raw subject included, filtering out reserved JWT claims
441    let mut custom_claims = claims.sanitized_custom();
442    custom_claims.insert("sub".to_string(), serde_json::Value::String(claims.sub));
443
444    match user_id {
445        Some(uuid) => {
446            // Subject is a valid UUID
447            AuthContext::authenticated(uuid, claims.roles, custom_claims)
448        }
449        None => {
450            // Subject is not a UUID (e.g., Firebase uid, Clerk user_xxx, email)
451            // Still authenticated, but user_id() will return None
452            AuthContext::authenticated_without_uuid(claims.roles, custom_claims)
453        }
454    }
455}
456
457/// Authentication middleware function.
458pub async fn auth_middleware(
459    State(middleware): State<Arc<AuthMiddleware>>,
460    req: Request<Body>,
461    next: Next,
462) -> Response {
463    let token = match extract_token(&req) {
464        Ok(token) => token,
465        Err(e) => {
466            tracing::warn!(error = %e, "Invalid authorization header");
467            return (
468                StatusCode::UNAUTHORIZED,
469                Json(serde_json::json!({
470                    "success": false,
471                    "error": { "code": "UNAUTHORIZED", "message": "Invalid authorization header" }
472                })),
473            )
474                .into_response();
475        }
476    };
477    tracing::trace!(
478        token_present = token.is_some(),
479        "Auth middleware processing request"
480    );
481
482    let auth_context = match extract_auth_context_async(token, &middleware).await {
483        Ok(auth_context) => auth_context,
484        Err(e) => {
485            tracing::warn!(error = %e, "Token validation failed");
486            return (
487                StatusCode::UNAUTHORIZED,
488                Json(serde_json::json!({
489                    "success": false,
490                    "error": { "code": "UNAUTHORIZED", "message": "Invalid authentication token" }
491                })),
492            )
493                .into_response();
494        }
495    };
496    tracing::trace!(
497        authenticated = auth_context.is_authenticated(),
498        "Auth context created"
499    );
500
501    // Set OAuth session cookie when user is authenticated and HMAC secret
502    // is available. This identifies the user on the OAuth authorize page
503    // (same backend origin) without needing cross-origin localStorage.
504    // Requires CORS Access-Control-Allow-Credentials: true and frontend
505    // fetch with credentials: 'include' for the Set-Cookie to stick.
506    let should_set_cookie =
507        auth_context.is_authenticated() && middleware.config.jwt_secret.is_some();
508
509    let req_is_https = req
510        .headers()
511        .get("x-forwarded-proto")
512        .and_then(|v| v.to_str().ok())
513        .map(|s| s == "https")
514        .unwrap_or(false);
515
516    // Skip cookie if one already exists (avoids resigning on every request)
517    let has_session_cookie = req
518        .headers()
519        .get(header::COOKIE)
520        .and_then(|v| v.to_str().ok())
521        .map(|c| c.contains("forge_session="))
522        .unwrap_or(false);
523
524    let should_set_cookie = should_set_cookie && !has_session_cookie;
525
526    let mut req = req;
527    req.extensions_mut().insert(auth_context.clone());
528
529    let mut response = next.run(req).await;
530
531    if should_set_cookie
532        && let Some(subject) = auth_context.subject()
533        && let Some(secret) = &middleware.config.jwt_secret
534    {
535        let cookie_value = sign_session_cookie(subject, secret);
536        let secure_flag = if req_is_https { "; Secure" } else { "" };
537        let cookie = format!(
538            "forge_session={cookie_value}; Path=/_api/oauth/; HttpOnly; SameSite=Lax; Max-Age=86400{secure_flag}"
539        );
540        if let Ok(val) = axum::http::HeaderValue::from_str(&cookie) {
541            response.headers_mut().append(header::SET_COOKIE, val);
542        }
543    }
544
545    response
546}
547
548/// OAuth session cookie format: `subject.expiry_unix.hmac_signature`
549/// The cookie identifies a user for the OAuth consent page without requiring
550/// localStorage (which doesn't work cross-origin in dev).
551pub fn sign_session_cookie(subject: &str, secret: &str) -> String {
552    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
553    use hmac::{Hmac, Mac};
554    use sha2::Sha256;
555
556    let expiry = chrono::Utc::now().timestamp() + 86400; // 24h
557    let payload = format!("{subject}.{expiry}");
558
559    let mut mac =
560        Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
561    mac.update(payload.as_bytes());
562    let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
563
564    format!("{payload}.{sig}")
565}
566
567/// Verify and extract the subject from a session cookie.
568/// Returns None if expired, tampered, or malformed.
569pub fn verify_session_cookie(cookie_value: &str, secret: &str) -> Option<String> {
570    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
571    use hmac::{Hmac, Mac};
572    use sha2::Sha256;
573
574    let parts: Vec<&str> = cookie_value.rsplitn(2, '.').collect();
575    if parts.len() != 2 {
576        return None;
577    }
578    let sig_encoded = parts.first()?;
579    let payload = parts.get(1)?; // "subject.expiry"
580
581    // Verify signature (HMAC verify_slice is constant-time)
582    let sig_bytes = URL_SAFE_NO_PAD.decode(sig_encoded).ok()?;
583    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).ok()?;
584    mac.update(payload.as_bytes());
585    mac.verify_slice(&sig_bytes).ok()?;
586
587    // Extract subject and expiry
588    let dot_pos = payload.rfind('.')?;
589    let subject = &payload[..dot_pos];
590    let expiry_str = &payload[dot_pos + 1..];
591    let expiry: i64 = expiry_str.parse().ok()?;
592
593    if chrono::Utc::now().timestamp() > expiry {
594        return None;
595    }
596
597    Some(subject.to_string())
598}
599
600#[cfg(test)]
601#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
602mod tests {
603    use super::*;
604    use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
605    use hmac::{Hmac, Mac};
606    use jsonwebtoken::{EncodingKey, Header, encode};
607    use sha2::Sha256;
608
609    fn create_test_claims(expired: bool) -> Claims {
610        use forge_core::auth::ClaimsBuilder;
611
612        let mut builder = ClaimsBuilder::new().subject("test-user-id").role("user");
613
614        if expired {
615            builder = builder.duration_secs(-3600); // Expired 1 hour ago
616        } else {
617            builder = builder.duration_secs(3600); // Valid for 1 hour
618        }
619
620        builder.build().unwrap()
621    }
622
623    fn create_test_token(claims: &Claims, secret: &str) -> String {
624        encode(
625            &Header::default(),
626            claims,
627            &EncodingKey::from_secret(secret.as_bytes()),
628        )
629        .unwrap()
630    }
631
632    fn session_cookie_with_expiry(subject: &str, secret: &str, expiry: i64) -> String {
633        let payload = format!("{subject}.{expiry}");
634        let mut mac =
635            Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key");
636        mac.update(payload.as_bytes());
637        let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
638        format!("{payload}.{sig}")
639    }
640
641    #[test]
642    fn test_auth_config_default() {
643        let config = AuthConfig::default();
644        assert_eq!(config.algorithm, JwtAlgorithm::HS256);
645        assert!(!config.skip_verification);
646    }
647
648    #[test]
649    fn test_auth_config_dev_mode() {
650        let config = AuthConfig::dev_mode();
651        assert!(config.skip_verification);
652    }
653
654    #[test]
655    fn test_auth_middleware_permissive() {
656        let middleware = AuthMiddleware::permissive();
657        assert!(middleware.config.skip_verification);
658    }
659
660    #[tokio::test]
661    async fn test_valid_token_with_correct_secret() {
662        let secret = "test-secret-key";
663        let config = AuthConfig::with_secret(secret);
664        let middleware = AuthMiddleware::new(config);
665
666        let claims = create_test_claims(false);
667        let token = create_test_token(&claims, secret);
668
669        let result = middleware.validate_token_async(&token).await;
670        assert!(result.is_ok());
671        let validated_claims = result.unwrap();
672        assert_eq!(validated_claims.sub, "test-user-id");
673    }
674
675    #[tokio::test]
676    async fn test_valid_token_with_wrong_secret() {
677        let config = AuthConfig::with_secret("correct-secret");
678        let middleware = AuthMiddleware::new(config);
679
680        let claims = create_test_claims(false);
681        let token = create_test_token(&claims, "wrong-secret");
682
683        let result = middleware.validate_token_async(&token).await;
684        assert!(result.is_err());
685        match result {
686            Err(AuthError::InvalidToken(_)) => {}
687            _ => panic!("Expected InvalidToken error"),
688        }
689    }
690
691    #[tokio::test]
692    async fn test_expired_token() {
693        let secret = "test-secret";
694        let config = AuthConfig::with_secret(secret);
695        let middleware = AuthMiddleware::new(config);
696
697        let claims = create_test_claims(true); // Expired
698        let token = create_test_token(&claims, secret);
699
700        let result = middleware.validate_token_async(&token).await;
701        assert!(result.is_err());
702        match result {
703            Err(AuthError::TokenExpired) => {}
704            _ => panic!("Expected TokenExpired error"),
705        }
706    }
707
708    #[tokio::test]
709    async fn test_tampered_token() {
710        let secret = "test-secret";
711        let config = AuthConfig::with_secret(secret);
712        let middleware = AuthMiddleware::new(config);
713
714        let claims = create_test_claims(false);
715        let mut token = create_test_token(&claims, secret);
716
717        // Tamper with the token by modifying a character in the signature
718        if let Some(last_char) = token.pop() {
719            let replacement = if last_char == 'a' { 'b' } else { 'a' };
720            token.push(replacement);
721        }
722
723        let result = middleware.validate_token_async(&token).await;
724        assert!(result.is_err());
725    }
726
727    #[tokio::test]
728    async fn test_dev_mode_skips_signature() {
729        let config = AuthConfig::dev_mode();
730        let middleware = AuthMiddleware::new(config);
731
732        // Create token with any secret
733        let claims = create_test_claims(false);
734        let token = create_test_token(&claims, "any-secret");
735
736        // Should still validate in dev mode
737        let result = middleware.validate_token_async(&token).await;
738        assert!(result.is_ok());
739    }
740
741    #[tokio::test]
742    async fn test_dev_mode_still_checks_expiration() {
743        let config = AuthConfig::dev_mode();
744        let middleware = AuthMiddleware::new(config);
745
746        let claims = create_test_claims(true); // Expired
747        let token = create_test_token(&claims, "any-secret");
748
749        let result = middleware.validate_token_async(&token).await;
750        assert!(result.is_err());
751        match result {
752            Err(AuthError::TokenExpired) => {}
753            _ => panic!("Expected TokenExpired error even in dev mode"),
754        }
755    }
756
757    #[tokio::test]
758    async fn test_invalid_token_format() {
759        let config = AuthConfig::with_secret("secret");
760        let middleware = AuthMiddleware::new(config);
761
762        let result = middleware.validate_token_async("not-a-valid-jwt").await;
763        assert!(result.is_err());
764        match result {
765            Err(AuthError::InvalidToken(_)) => {}
766            _ => panic!("Expected InvalidToken error"),
767        }
768    }
769
770    #[test]
771    fn test_algorithm_conversion() {
772        // HMAC algorithms
773        assert_eq!(Algorithm::from(JwtAlgorithm::HS256), Algorithm::HS256);
774        assert_eq!(Algorithm::from(JwtAlgorithm::HS384), Algorithm::HS384);
775        assert_eq!(Algorithm::from(JwtAlgorithm::HS512), Algorithm::HS512);
776        // RSA algorithms
777        assert_eq!(Algorithm::from(JwtAlgorithm::RS256), Algorithm::RS256);
778        assert_eq!(Algorithm::from(JwtAlgorithm::RS384), Algorithm::RS384);
779        assert_eq!(Algorithm::from(JwtAlgorithm::RS512), Algorithm::RS512);
780    }
781
782    #[test]
783    fn test_is_hmac_and_is_rsa() {
784        let hmac_config = AuthConfig::with_secret("test");
785        assert!(hmac_config.is_hmac());
786        assert!(!hmac_config.is_rsa());
787
788        let rsa_config = AuthConfig {
789            algorithm: JwtAlgorithm::RS256,
790            ..Default::default()
791        };
792        assert!(!rsa_config.is_hmac());
793        assert!(rsa_config.is_rsa());
794    }
795
796    #[test]
797    fn test_extract_token_rejects_non_bearer_header() {
798        let req = Request::builder()
799            .header(axum::http::header::AUTHORIZATION, "Basic abc")
800            .body(Body::empty())
801            .unwrap();
802
803        let result = extract_token(&req);
804        assert!(matches!(result, Err(AuthError::InvalidHeader)));
805    }
806
807    #[test]
808    fn test_build_auth_context_from_non_uuid_claims_preserves_subject() {
809        let claims = Claims::builder()
810            .subject("clerk_user_123")
811            .role("member")
812            .claim("tenant_id", serde_json::json!("tenant-1"))
813            .build()
814            .unwrap();
815
816        let auth = build_auth_context_from_claims(claims);
817        assert!(auth.is_authenticated());
818        assert!(auth.user_id().is_none());
819        assert_eq!(auth.subject(), Some("clerk_user_123"));
820        assert_eq!(auth.principal_id(), Some("clerk_user_123".to_string()));
821        assert!(auth.has_role("member"));
822        assert_eq!(
823            auth.claim("sub"),
824            Some(&serde_json::json!("clerk_user_123"))
825        );
826    }
827
828    #[test]
829    fn test_verify_session_cookie_round_trip_and_tamper_detection() {
830        let cookie = sign_session_cookie("user-123", "session-secret");
831
832        assert_eq!(
833            verify_session_cookie(&cookie, "session-secret"),
834            Some("user-123".to_string())
835        );
836
837        let mut tampered = cookie.clone();
838        if let Some(last_char) = tampered.pop() {
839            tampered.push(if last_char == 'a' { 'b' } else { 'a' });
840        }
841
842        assert_eq!(verify_session_cookie(&tampered, "session-secret"), None);
843        assert_eq!(verify_session_cookie(&cookie, "wrong-secret"), None);
844    }
845
846    #[test]
847    fn test_verify_session_cookie_rejects_expired_cookie() {
848        let expired_cookie = session_cookie_with_expiry(
849            "user-123",
850            "session-secret",
851            chrono::Utc::now().timestamp() - 1,
852        );
853
854        assert_eq!(
855            verify_session_cookie(&expired_cookie, "session-secret"),
856            None
857        );
858    }
859
860    #[tokio::test]
861    async fn test_extract_auth_context_async_invalid_token_errors() {
862        let middleware = AuthMiddleware::new(AuthConfig::with_secret("secret"));
863        let result = extract_auth_context_async(Some("bad.token".to_string()), &middleware).await;
864        assert!(matches!(result, Err(AuthError::InvalidToken(_))));
865    }
866}