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#[derive(Debug, Clone)]
20pub struct AuthConfig {
21 pub jwt_secret: Option<String>,
23 pub algorithm: JwtAlgorithm,
25 pub jwks_client: Option<Arc<JwksClient>>,
27 pub issuer: Option<String>,
29 pub audience: Option<String>,
31 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 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 pub fn with_secret(secret: impl Into<String>) -> Self {
75 Self {
76 jwt_secret: Some(secret.into()),
77 ..Default::default()
78 }
79 }
80
81 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 pub fn is_hmac(&self) -> bool {
109 matches!(
110 self.algorithm,
111 JwtAlgorithm::HS256 | JwtAlgorithm::HS384 | JwtAlgorithm::HS512
112 )
113 }
114
115 pub fn is_rsa(&self) -> bool {
117 matches!(
118 self.algorithm,
119 JwtAlgorithm::RS256 | JwtAlgorithm::RS384 | JwtAlgorithm::RS512
120 )
121 }
122}
123
124#[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#[derive(Clone)]
167pub struct HmacTokenIssuer {
168 secret: String,
169 algorithm: Algorithm,
170}
171
172impl HmacTokenIssuer {
173 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#[derive(Clone)]
211pub struct AuthMiddleware {
212 config: Arc<AuthConfig>,
213 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 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 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 pub fn permissive() -> Self {
255 Self::new(AuthConfig::dev_mode())
256 }
257
258 pub fn config(&self) -> &AuthConfig {
260 &self.config
261 }
262
263 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 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 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 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 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 fn decode_and_validate(&self, token: &str, key: &DecodingKey) -> Result<Claims, AuthError> {
313 let mut validation = Validation::new(self.config.algorithm.into());
314
315 validation.validate_exp = true;
317 validation.validate_nbf = true;
318 validation.leeway = 60; validation.set_required_spec_claims(&["exp", "sub"]);
322
323 if let Some(ref issuer) = self.config.issuer {
325 validation.set_issuer(&[issuer]);
326 }
327
328 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 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 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 if token_data.claims.is_expired() {
376 return Err(AuthError::TokenExpired);
377 }
378
379 Ok(token_data.claims)
380 }
381}
382
383#[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
396pub 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
417pub 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
431pub fn build_auth_context_from_claims(claims: Claims) -> AuthContext {
437 let user_id = claims.user_id();
439
440 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 AuthContext::authenticated(uuid, claims.roles, custom_claims)
448 }
449 None => {
450 AuthContext::authenticated_without_uuid(claims.roles, custom_claims)
453 }
454 }
455}
456
457pub 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 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 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
548pub 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; 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
567pub 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)?; 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 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); } else {
617 builder = builder.duration_secs(3600); }
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); 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 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 let claims = create_test_claims(false);
734 let token = create_test_token(&claims, "any-secret");
735
736 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); 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 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 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}