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