Skip to main content

chronix_security/auth/
jwt.rs

1//! JWT and OIDC token validation.
2//!
3//! Supports the HMAC (`HS*`), RSA (`RS*`, `PS*`), elliptic-curve (`ES*`)
4//! and Edwards-curve (`EdDSA`) families.
5//! Can fetch JWKS from an OIDC discovery endpoint or use a static secret.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use chronix_security::auth::jwt::{JwtValidator, JwtConfig};
11//! use zeroize::Zeroizing;
12//!
13//! let config = JwtConfig {
14//!     secret: Some(Zeroizing::new("my-secret-key-at-least-32-bytes!".to_string())),
15//!     issuer: Some("https://auth.example.com".to_string()),
16//!     audience: Some("chronix".to_string()),
17//!     ..Default::default()
18//! };
19//! let validator = JwtValidator::new(config).unwrap();
20//! ```
21
22use std::collections::HashMap;
23
24use std::sync::Arc;
25
26use dashmap::DashMap;
27use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, TokenData, Validation};
28use serde::{Deserialize, Serialize};
29use zeroize::Zeroizing;
30
31use crate::auth::error::AuthError;
32use crate::auth::oidc::JwksCache;
33
34/// Serialize `Option<Zeroizing<String>>` as `Option<String>`.
35fn ser_zeroizing_opt<S: serde::Serializer>(
36    val: &Option<Zeroizing<String>>,
37    ser: S,
38) -> Result<S::Ok, S::Error> {
39    match val {
40        Some(z) => ser.serialize_some(z.as_str()),
41        None => ser.serialize_none(),
42    }
43}
44
45/// Deserialize `Option<String>` into `Option<Zeroizing<String>>`.
46fn de_zeroizing_opt<'de, D: serde::Deserializer<'de>>(
47    de: D,
48) -> Result<Option<Zeroizing<String>>, D::Error> {
49    let opt: Option<String> = Option::deserialize(de)?;
50    Ok(opt.map(Zeroizing::new))
51}
52
53/// Serde default helper that returns `true`.
54fn default_true() -> bool {
55    true
56}
57
58/// JWT validation configuration.
59///
60/// **Security:** The `secret` and `public_key_pem` fields are redacted
61/// in `Debug` output to prevent credential leakage in logs.
62#[derive(Clone, Default, Serialize, Deserialize)]
63pub struct JwtConfig {
64    /// HMAC secret for HS256/HS384/HS512 (base64-encoded or raw).
65    #[serde(
66        serialize_with = "ser_zeroizing_opt",
67        deserialize_with = "de_zeroizing_opt",
68        default
69    )]
70    pub secret: Option<Zeroizing<String>>,
71    /// Public key PEM for the asymmetric families.
72    ///
73    /// RSA (`RS*`, `PS*`) and elliptic-curve (`ES*`) keys both live here:
74    /// the family comes from `algorithm`, and mixing the two used to mean
75    /// an EC key was parsed as RSA, failed, and left the validator with no
76    /// key at all — which every token then failed against.
77    #[serde(
78        serialize_with = "ser_zeroizing_opt",
79        deserialize_with = "de_zeroizing_opt",
80        default
81    )]
82    pub public_key_pem: Option<Zeroizing<String>>,
83    /// Expected issuer (`iss` claim).
84    pub issuer: Option<String>,
85    /// Expected audience (`aud` claim).
86    pub audience: Option<String>,
87    /// Whether to require audience validation.  When `true` (default)
88    /// and `audience` is `None`, validator construction fails.
89    /// Set to `false` only if tokens legitimately carry no audience.
90    #[serde(default = "default_true")]
91    pub require_audience: bool,
92    /// JWKS URL for fetching public keys (OIDC discovery).
93    pub jwks_url: Option<String>,
94    /// Claim name for the principal identity (default: `sub`).
95    pub subject_claim: Option<String>,
96    /// Algorithm to use. Default: HS256.
97    pub algorithm: Option<String>,
98    /// Claim name or dot-separated path for role extraction (default: `roles`).
99    /// Examples: `"roles"`, `"realm_access.roles"`, `"https://example.com/roles"`.
100    pub role_claim: Option<String>,
101}
102
103impl std::fmt::Debug for JwtConfig {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("JwtConfig")
106            .field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
107            .field(
108                "public_key_pem",
109                &self.public_key_pem.as_ref().map(|_| "[REDACTED]"),
110            )
111            .field("issuer", &self.issuer)
112            .field("audience", &self.audience)
113            .field("jwks_url", &self.jwks_url)
114            .field("subject_claim", &self.subject_claim)
115            .field("role_claim", &self.role_claim)
116            .field("algorithm", &self.algorithm)
117            .finish()
118    }
119}
120
121/// Validated JWT claims.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct JwtClaims {
124    /// Subject — the principal identity.
125    #[serde(default)]
126    pub sub: Option<String>,
127    /// Issuer.
128    #[serde(default)]
129    pub iss: Option<String>,
130    /// Audience.
131    #[serde(default)]
132    pub aud: Option<JwtAudience>,
133    /// Expiration time (Unix timestamp).
134    #[serde(default)]
135    pub exp: Option<u64>,
136    /// Issued at (Unix timestamp).
137    #[serde(default)]
138    pub iat: Option<u64>,
139    /// Email claim.
140    #[serde(default)]
141    pub email: Option<String>,
142    /// JWT ID for replay detection. Optional per RFC 7519.
143    #[serde(default)]
144    pub jti: Option<String>,
145    /// Custom claims.
146    #[serde(flatten)]
147    pub extra: HashMap<String, serde_json::Value>,
148}
149
150/// JWT audience can be a single string or an array.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(untagged)]
153pub enum JwtAudience {
154    /// Single audience string.
155    Single(String),
156    /// Multiple audience strings.
157    Multiple(Vec<String>),
158}
159
160/// JWT token validator.
161///
162/// Supports three key sources (checked in order):
163/// 1. Static secret or RSA PEM key
164/// 2. JWKS cache (fetched from OIDC discovery or direct JWKS URL)
165#[derive(Clone)]
166pub struct JwtValidator {
167    config: JwtConfig,
168    decoding_key: Option<DecodingKey>,
169    validation: Validation,
170    /// Optional JWKS cache for OIDC / dynamic key rotation.
171    jwks_cache: Option<Arc<JwksCache>>,
172    /// Bounded `jti` replay cache. Key is the jti string,
173    /// value is the token expiry timestamp (Unix seconds). Entries
174    /// are evicted when they exceed `exp`.
175    jti_cache: Arc<DashMap<String, u64>>,
176}
177
178impl std::fmt::Debug for JwtValidator {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.debug_struct("JwtValidator")
181            .field("config", &self.config)
182            .field("has_decoding_key", &self.decoding_key.is_some())
183            .field("has_jwks_cache", &self.jwks_cache.is_some())
184            .finish()
185    }
186}
187
188impl JwtValidator {
189    /// Create a new JWT validator from configuration.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`AuthError::Config`] if the configured algorithm string
194    /// is unrecognized (fail-closed).
195    pub fn new(config: JwtConfig) -> Result<Self, AuthError> {
196        let algorithm = match config.algorithm.as_deref() {
197            Some(s) => parse_algorithm(s)?,
198            None => Algorithm::HS256,
199        };
200
201        let mut validation = Validation::new(algorithm);
202
203        if let Some(ref iss) = config.issuer {
204            validation.set_issuer(&[iss]);
205        }
206
207        if let Some(ref aud) = config.audience {
208            validation.set_audience(&[aud]);
209        } else if config.require_audience {
210            return Err(AuthError::Config(
211                "audience is required but not configured; set `audience` or \
212                 set `require_audience = false` to opt out"
213                    .into(),
214            ));
215        } else {
216            validation.validate_aud = false;
217        }
218
219        // Require exp claim
220        validation.set_required_spec_claims(&["exp"]);
221
222        // Validate `nbf` (not-before) claim when present.
223        // The claim is not required — many IdPs omit it — but when
224        // present, the token must not be accepted before that time.
225        validation.validate_nbf = true;
226
227        // **The key is chosen by the algorithm's family, not by which
228        // field happens to be set.** A configuration naming RS256 while a
229        // `secret` was present built an HMAC key from the secret and
230        // verified an RSA signature against it, so every token was
231        // rejected — a 401 with nothing in the log to say why. Picking the
232        // key from the algorithm makes the mismatch a startup error.
233        // **The key is chosen by the algorithm's family, not by whichever
234        // field happens to be set.** A configuration naming RS256 while a
235        // `secret` was present built an HMAC key from that secret and
236        // verified an RSA signature against it, so every token was
237        // rejected — a 401 with nothing in the log to say why.
238        //
239        // No key material at all stays `None`: a JWKS cache may be attached
240        // afterwards, and that is the whole point of an OIDC deployment.
241        // Material that cannot belong to this algorithm is a startup error,
242        // because it can never verify anything.
243        let hmac_family = matches!(
244            algorithm,
245            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
246        );
247        if hmac_family && config.public_key_pem.is_some() {
248            return Err(AuthError::Config(format!(
249                "{algorithm:?} is an HMAC algorithm and takes `secret`, not `public_key_pem`"
250            )));
251        }
252        if !hmac_family && config.secret.is_some() && config.public_key_pem.is_none() {
253            return Err(AuthError::Config(format!(
254                "{algorithm:?} verifies with a public key; set `public_key_pem` \
255                 (a `secret` cannot verify an asymmetric signature)"
256            )));
257        }
258
259        let decoding_key = match (
260            algorithm,
261            config.secret.as_ref(),
262            config.public_key_pem.as_ref(),
263        ) {
264            (_, None, None) => None,
265            (Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512, Some(secret), _) => {
266                // NIST SP 800-107: the key is at least as long as the hash.
267                if secret.len() < 32 {
268                    return Err(AuthError::Config(
269                        "HMAC secret must be at least 32 bytes".into(),
270                    ));
271                }
272                Some(DecodingKey::from_secret(secret.as_bytes()))
273            }
274            (
275                Algorithm::RS256
276                | Algorithm::RS384
277                | Algorithm::RS512
278                | Algorithm::PS256
279                | Algorithm::PS384
280                | Algorithm::PS512,
281                _,
282                Some(pem),
283            ) => Some(DecodingKey::from_rsa_pem(pem.as_bytes()).map_err(|e| {
284                AuthError::Config(format!(
285                    "{algorithm:?} public key is not valid RSA PEM: {e}"
286                ))
287            })?),
288            (Algorithm::ES256 | Algorithm::ES384, _, Some(pem)) => {
289                Some(DecodingKey::from_ec_pem(pem.as_bytes()).map_err(|e| {
290                    AuthError::Config(format!("{algorithm:?} public key is not valid EC PEM: {e}"))
291                })?)
292            }
293            (Algorithm::EdDSA, _, Some(pem)) => {
294                Some(DecodingKey::from_ed_pem(pem.as_bytes()).map_err(|e| {
295                    AuthError::Config(format!("EdDSA public key is not valid PEM: {e}"))
296                })?)
297            }
298            (other, _, _) => {
299                return Err(AuthError::Config(format!(
300                    "JWT algorithm {other:?} is not supported"
301                )))
302            }
303        };
304
305        Ok(Self {
306            config,
307            decoding_key,
308            validation,
309            jwks_cache: None,
310            jti_cache: Arc::new(DashMap::new()),
311        })
312    }
313
314    /// Attach a [`JwksCache`] for dynamic OIDC key resolution.
315    ///
316    /// When set, [`validate_async`](Self::validate_async) will try the JWKS
317    /// cache if no static key is configured (or if static validation fails).
318    #[must_use]
319    pub fn with_jwks_cache(mut self, cache: Arc<JwksCache>) -> Self {
320        self.jwks_cache = Some(cache);
321        self
322    }
323
324    /// Returns a reference to the attached JWKS cache, if any.
325    #[must_use]
326    pub fn jwks_cache(&self) -> Option<&Arc<JwksCache>> {
327        self.jwks_cache.as_ref()
328    }
329
330    /// Validate a JWT token string and return the claims.
331    pub fn validate(&self, token: &str) -> Result<JwtClaims, AuthError> {
332        let key = self
333            .decoding_key
334            .as_ref()
335            .ok_or_else(|| AuthError::Config("no decoding key configured".into()))?;
336
337        let token_data: TokenData<JwtClaims> =
338            decode(token, key, &self.validation).map_err(|e| match e.kind() {
339                jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::ExpiredJwt,
340                _ => AuthError::InvalidJwt(e.to_string()),
341            })?;
342
343        // Replay protection — if `jti` is present, reject duplicates.
344        self.check_jti_replay(&token_data.claims)?;
345
346        Ok(token_data.claims)
347    }
348
349    /// Check for JWT replay attacks using the `jti` claim.
350    ///
351    /// If a `jti` is present, ensures it hasn't been seen before. The cache
352    /// is bounded: expired entries are evicted on each check to prevent
353    /// unbounded growth.
354    fn check_jti_replay(&self, claims: &JwtClaims) -> Result<(), AuthError> {
355        // Evict expired entries (bounded maintenance).
356        let now = std::time::SystemTime::now()
357            .duration_since(std::time::UNIX_EPOCH)
358            .unwrap_or_default()
359            .as_secs();
360        self.jti_cache.retain(|_, exp| *exp > now);
361
362        if let Some(ref jti) = claims.jti {
363            // Hard cap to prevent DoS via large cache.
364            // Fail-closed when cache is exhausted — reject the
365            // token instead of silently skipping replay detection.
366            const MAX_JTI_CACHE: usize = 100_000;
367            if self.jti_cache.len() >= MAX_JTI_CACHE {
368                tracing::error!(
369                    cache_size = self.jti_cache.len(),
370                    "jti replay cache exhausted — rejecting token (fail-closed)"
371                );
372                return Err(AuthError::InvalidJwt(
373                    "replay cache exhausted — try again later".into(),
374                ));
375            }
376            let exp = claims.exp.unwrap_or(now + 3600);
377            if self.jti_cache.insert(jti.clone(), exp).is_some() {
378                return Err(AuthError::InvalidJwt(
379                    "token replay detected (duplicate jti)".into(),
380                ));
381            }
382        }
383        Ok(())
384    }
385
386    /// Validate a JWT token with JWKS cache support (async).
387    ///
388    /// Resolution order:
389    /// 1. Try static key (secret / RSA PEM) if configured.
390    /// 2. Fall back to the JWKS cache: decode the JWT header to extract `kid`,
391    ///    fetch the matching key from the cache, and validate.
392    /// 3. If no `kid` header, try all cached keys.
393    pub async fn validate_async(&self, token: &str) -> Result<JwtClaims, AuthError> {
394        // 1. Try static key first — if configured, errors are authoritative.
395        if self.decoding_key.is_some() {
396            match self.validate(token) {
397                ok @ Ok(_) => return ok,
398                Err(e) => {
399                    // If we also have a JWKS cache, give it a chance.
400                    // But for expiry errors there is no point retrying.
401                    if self.jwks_cache.is_none() || matches!(e, AuthError::ExpiredJwt) {
402                        return Err(e);
403                    }
404                    tracing::debug!(error = %e, "static key validation failed, falling back to JWKS");
405                }
406            }
407        }
408
409        // 2. Try JWKS cache.
410        let cache = self
411            .jwks_cache
412            .as_ref()
413            .ok_or_else(|| AuthError::Config("no decoding key or JWKS cache configured".into()))?;
414
415        // Decode header to get kid.
416        let header = decode_header(token)
417            .map_err(|e| AuthError::InvalidJwt(format!("failed to decode JWT header: {e}")))?;
418
419        if let Some(kid) = &header.kid {
420            let cached = cache.get_key(kid).await?;
421            let mut validation = self.validation.clone();
422            validation.algorithms = vec![cached.algorithm];
423            let token_data: TokenData<JwtClaims> = decode(token, &cached.key, &validation)
424                .map_err(|e| match e.kind() {
425                    jsonwebtoken::errors::ErrorKind::ExpiredSignature => AuthError::ExpiredJwt,
426                    _ => AuthError::InvalidJwt(e.to_string()),
427                })?;
428            return Ok(token_data.claims);
429        }
430
431        // 3. No kid — try all cached keys.
432        let all_keys = cache.get_all_keys().await?;
433        let mut last_err = AuthError::Config("no JWKS keys available".into());
434        for cached in &all_keys {
435            let mut validation = self.validation.clone();
436            validation.algorithms = vec![cached.algorithm];
437            match decode::<JwtClaims>(token, &cached.key, &validation) {
438                Ok(td) => return Ok(td.claims),
439                Err(e) => {
440                    match e.kind() {
441                        jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
442                            // The signature matched this key but the token is
443                            // expired — return immediately. Trying further keys
444                            // cannot fix an expired `exp` claim, and continuing
445                            // would risk overwriting this informative error with
446                            // a misleading `InvalidJwt` from a non-matching key.
447                            return Err(AuthError::ExpiredJwt);
448                        }
449                        _ => {
450                            last_err = AuthError::InvalidJwt(e.to_string());
451                        }
452                    }
453                }
454            }
455        }
456
457        Err(last_err)
458    }
459
460    /// Extract the principal identity from JWT claims.
461    pub fn extract_principal(&self, claims: &JwtClaims) -> Option<String> {
462        let claim_name = self.config.subject_claim.as_deref().unwrap_or("sub");
463
464        match claim_name {
465            "sub" => claims.sub.clone(),
466            "email" => claims.email.clone(),
467            other => claims
468                .extra
469                .get(other)
470                .and_then(|v| v.as_str())
471                .map(String::from),
472        }
473    }
474}
475
476/// Parse algorithm string to `jsonwebtoken::Algorithm`.
477///
478/// Returns an error for unrecognized algorithm strings so that
479/// configuration typos are caught at startup (fail-closed).
480fn parse_algorithm(s: &str) -> Result<Algorithm, AuthError> {
481    match s.to_uppercase().as_str() {
482        "HS256" => Ok(Algorithm::HS256),
483        "HS384" => Ok(Algorithm::HS384),
484        "HS512" => Ok(Algorithm::HS512),
485        "RS256" => Ok(Algorithm::RS256),
486        "RS384" => Ok(Algorithm::RS384),
487        "RS512" => Ok(Algorithm::RS512),
488        "ES256" => Ok(Algorithm::ES256),
489        "ES384" => Ok(Algorithm::ES384),
490        "PS256" => Ok(Algorithm::PS256),
491        "PS384" => Ok(Algorithm::PS384),
492        "PS512" => Ok(Algorithm::PS512),
493        "EDDSA" => Ok(Algorithm::EdDSA),
494        unknown => Err(AuthError::Config(format!(
495            "unrecognized JWT algorithm: {unknown}"
496        ))),
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use jsonwebtoken::{encode, EncodingKey, Header};
504
505    fn make_token(claims: &JwtClaims, secret: &str) -> String {
506        let header = Header::new(Algorithm::HS256);
507        let key = EncodingKey::from_secret(secret.as_bytes());
508        encode(&header, claims, &key).unwrap()
509    }
510
511    fn future_exp() -> u64 {
512        std::time::SystemTime::now()
513            .duration_since(std::time::UNIX_EPOCH)
514            .unwrap()
515            .as_secs()
516            + 3600
517    }
518
519    #[test]
520    fn reject_short_hmac_secret() {
521        let config = JwtConfig {
522            secret: Some(Zeroizing::new("too-short".into())),
523            ..Default::default()
524        };
525        let err = JwtValidator::new(config).unwrap_err();
526        assert!(matches!(err, AuthError::Config(_)));
527    }
528
529    #[test]
530    fn reject_missing_audience_when_required() {
531        let config = JwtConfig {
532            secret: Some(Zeroizing::new("test-secret-at-least-32-bytes!!!".into())),
533            require_audience: true,
534            audience: None,
535            ..Default::default()
536        };
537        let err = JwtValidator::new(config).unwrap_err();
538        assert!(matches!(err, AuthError::Config(_)));
539    }
540
541    #[test]
542    fn allow_missing_audience_when_not_required() {
543        let config = JwtConfig {
544            secret: Some(Zeroizing::new("test-secret-at-least-32-bytes!!!".into())),
545            require_audience: false,
546            audience: None,
547            ..Default::default()
548        };
549        assert!(JwtValidator::new(config).is_ok());
550    }
551
552    #[test]
553    fn validate_valid_token() {
554        let secret = "test-secret-key-for-unit-tests!!"; // 32 bytes
555        let config = JwtConfig {
556            secret: Some(Zeroizing::new(secret.into())),
557            ..Default::default()
558        };
559        let validator = JwtValidator::new(config).unwrap();
560
561        let claims = JwtClaims {
562            sub: Some("user-123".into()),
563            exp: Some(future_exp()),
564            iss: None,
565            aud: None,
566            iat: None,
567            email: None,
568            jti: None,
569            extra: HashMap::new(),
570        };
571
572        let token = make_token(&claims, secret);
573        let validated = validator.validate(&token).unwrap();
574        assert_eq!(validated.sub, Some("user-123".into()));
575    }
576
577    #[test]
578    fn reject_expired_token() {
579        let secret = "test-secret-at-least-32-bytes!!!";
580        let config = JwtConfig {
581            secret: Some(Zeroizing::new(secret.into())),
582            ..Default::default()
583        };
584        let validator = JwtValidator::new(config).unwrap();
585
586        let claims = JwtClaims {
587            sub: Some("user".into()),
588            exp: Some(0), // expired
589            iss: None,
590            aud: None,
591            iat: None,
592            email: None,
593            jti: None,
594            extra: HashMap::new(),
595        };
596
597        let token = make_token(&claims, secret);
598        let err = validator.validate(&token).unwrap_err();
599        assert!(matches!(err, AuthError::ExpiredJwt));
600    }
601
602    #[test]
603    fn reject_wrong_secret() {
604        let config = JwtConfig {
605            secret: Some(Zeroizing::new("correct-secret-at-least-32bytes!".into())),
606            ..Default::default()
607        };
608        let validator = JwtValidator::new(config).unwrap();
609
610        let claims = JwtClaims {
611            sub: Some("user".into()),
612            exp: Some(future_exp()),
613            iss: None,
614            aud: None,
615            iat: None,
616            email: None,
617            jti: None,
618            extra: HashMap::new(),
619        };
620
621        let token = make_token(&claims, "wrong-secret--at-least-32bytes!");
622        let err = validator.validate(&token).unwrap_err();
623        assert!(matches!(err, AuthError::InvalidJwt(_)));
624    }
625
626    #[test]
627    fn validate_with_issuer() {
628        let secret = "test-secret-at-least-32-bytes!!!";
629        let config = JwtConfig {
630            secret: Some(Zeroizing::new(secret.into())),
631            issuer: Some("https://auth.example.com".into()),
632            ..Default::default()
633        };
634        let validator = JwtValidator::new(config).unwrap();
635
636        let claims = JwtClaims {
637            sub: Some("user".into()),
638            iss: Some("https://auth.example.com".into()),
639            exp: Some(future_exp()),
640            aud: None,
641            iat: None,
642            email: None,
643            jti: None,
644            extra: HashMap::new(),
645        };
646
647        let token = make_token(&claims, secret);
648        validator.validate(&token).unwrap();
649    }
650
651    #[test]
652    fn reject_wrong_issuer() {
653        let secret = "test-secret-at-least-32-bytes!!!";
654        let config = JwtConfig {
655            secret: Some(Zeroizing::new(secret.into())),
656            issuer: Some("https://auth.example.com".into()),
657            ..Default::default()
658        };
659        let validator = JwtValidator::new(config).unwrap();
660
661        let claims = JwtClaims {
662            sub: Some("user".into()),
663            iss: Some("https://evil.com".into()),
664            exp: Some(future_exp()),
665            aud: None,
666            iat: None,
667            email: None,
668            jti: None,
669            extra: HashMap::new(),
670        };
671
672        let token = make_token(&claims, secret);
673        assert!(validator.validate(&token).is_err());
674    }
675
676    #[test]
677    fn extract_principal_from_sub() {
678        let config = JwtConfig::default();
679        let validator = JwtValidator::new(config).unwrap();
680
681        let claims = JwtClaims {
682            sub: Some("principal-1".into()),
683            email: Some("user@example.com".into()),
684            exp: None,
685            iss: None,
686            aud: None,
687            iat: None,
688            jti: None,
689            extra: HashMap::new(),
690        };
691
692        assert_eq!(
693            validator.extract_principal(&claims),
694            Some("principal-1".into())
695        );
696    }
697
698    #[test]
699    fn extract_principal_from_email() {
700        let config = JwtConfig {
701            subject_claim: Some("email".into()),
702            ..Default::default()
703        };
704        let validator = JwtValidator::new(config).unwrap();
705
706        let claims = JwtClaims {
707            sub: Some("user-id".into()),
708            email: Some("admin@chronix.io".into()),
709            exp: None,
710            iss: None,
711            aud: None,
712            iat: None,
713            jti: None,
714            extra: HashMap::new(),
715        };
716
717        assert_eq!(
718            validator.extract_principal(&claims),
719            Some("admin@chronix.io".into())
720        );
721    }
722
723    #[test]
724    fn extract_principal_from_custom_claim() {
725        let config = JwtConfig {
726            subject_claim: Some("username".into()),
727            ..Default::default()
728        };
729        let validator = JwtValidator::new(config).unwrap();
730
731        let mut extra = HashMap::new();
732        extra.insert("username".into(), serde_json::json!("admin"));
733
734        let claims = JwtClaims {
735            sub: None,
736            email: None,
737            exp: None,
738            iss: None,
739            aud: None,
740            iat: None,
741            jti: None,
742            extra,
743        };
744
745        assert_eq!(validator.extract_principal(&claims), Some("admin".into()));
746    }
747
748    // ------------------------------------------------------------------
749    // JWKS cache integration tests
750    // ------------------------------------------------------------------
751
752    fn make_token_with_kid(claims: &JwtClaims, secret: &str, kid: &str) -> String {
753        let mut header = Header::new(Algorithm::HS256);
754        header.kid = Some(kid.to_string());
755        let key = EncodingKey::from_secret(secret.as_bytes());
756        encode(&header, claims, &key).unwrap()
757    }
758
759    #[tokio::test]
760    async fn validate_async_with_static_key() {
761        let secret = "static-secret-at-least-32-bytes!";
762        let config = JwtConfig {
763            secret: Some(Zeroizing::new(secret.into())),
764            ..Default::default()
765        };
766        let validator = JwtValidator::new(config).unwrap();
767
768        let claims = JwtClaims {
769            sub: Some("user-static".into()),
770            exp: Some(future_exp()),
771            iss: None,
772            aud: None,
773            iat: None,
774            email: None,
775            jti: None,
776            extra: HashMap::new(),
777        };
778
779        let token = make_token(&claims, secret);
780        let validated = validator.validate_async(&token).await.unwrap();
781        assert_eq!(validated.sub, Some("user-static".into()));
782    }
783
784    #[tokio::test]
785    async fn validate_async_with_jwks_cache() {
786        let secret = "jwks-secret-at-least-32-bytes!!!";
787
788        // Pre-populate JWKS cache with the HS256 key under kid="key-1"
789        let mut keys = std::collections::HashMap::new();
790        keys.insert(
791            "key-1".to_string(),
792            crate::auth::oidc::CachedKey {
793                key: DecodingKey::from_secret(secret.as_bytes()),
794                algorithm: Algorithm::HS256,
795            },
796        );
797        let cache = Arc::new(crate::auth::oidc::JwksCache::from_keys(keys));
798
799        // No static key configured — only JWKS cache
800        let config = JwtConfig::default();
801        let validator = JwtValidator::new(config).unwrap().with_jwks_cache(cache);
802
803        let claims = JwtClaims {
804            sub: Some("oidc-user".into()),
805            exp: Some(future_exp()),
806            iss: None,
807            aud: None,
808            iat: None,
809            email: None,
810            jti: None,
811            extra: HashMap::new(),
812        };
813
814        let token = make_token_with_kid(&claims, secret, "key-1");
815        let validated = validator.validate_async(&token).await.unwrap();
816        assert_eq!(validated.sub, Some("oidc-user".into()));
817    }
818
819    #[tokio::test]
820    async fn validate_async_jwks_no_kid_tries_all() {
821        let secret = "no-kid-secret-at-least-32bytes!!";
822
823        let mut keys = std::collections::HashMap::new();
824        keys.insert(
825            "default".to_string(),
826            crate::auth::oidc::CachedKey {
827                key: DecodingKey::from_secret(secret.as_bytes()),
828                algorithm: Algorithm::HS256,
829            },
830        );
831        let cache = Arc::new(crate::auth::oidc::JwksCache::from_keys(keys));
832
833        let config = JwtConfig::default();
834        let validator = JwtValidator::new(config).unwrap().with_jwks_cache(cache);
835
836        let claims = JwtClaims {
837            sub: Some("anon".into()),
838            exp: Some(future_exp()),
839            iss: None,
840            aud: None,
841            iat: None,
842            email: None,
843            jti: None,
844            extra: HashMap::new(),
845        };
846
847        // Token without kid header
848        let token = make_token(&claims, secret);
849        let validated = validator.validate_async(&token).await.unwrap();
850        assert_eq!(validated.sub, Some("anon".into()));
851    }
852
853    #[tokio::test]
854    async fn validate_async_jwks_wrong_kid_fails() {
855        let secret = "wrong-kid-secret-at-least-32byte";
856
857        let mut keys = std::collections::HashMap::new();
858        keys.insert(
859            "real-kid".to_string(),
860            crate::auth::oidc::CachedKey {
861                key: DecodingKey::from_secret(secret.as_bytes()),
862                algorithm: Algorithm::HS256,
863            },
864        );
865        let cache = Arc::new(crate::auth::oidc::JwksCache::from_keys(keys));
866
867        let config = JwtConfig::default();
868        let validator = JwtValidator::new(config).unwrap().with_jwks_cache(cache);
869
870        let claims = JwtClaims {
871            sub: Some("user".into()),
872            exp: Some(future_exp()),
873            iss: None,
874            aud: None,
875            iat: None,
876            email: None,
877            jti: None,
878            extra: HashMap::new(),
879        };
880
881        let token = make_token_with_kid(&claims, secret, "unknown-kid");
882        let err = validator.validate_async(&token).await.unwrap_err();
883        assert!(matches!(err, AuthError::Config(_)));
884    }
885
886    #[tokio::test]
887    async fn validate_async_no_key_no_cache_fails() {
888        let config = JwtConfig::default();
889        let validator = JwtValidator::new(config).unwrap();
890
891        let claims = JwtClaims {
892            sub: Some("user".into()),
893            exp: Some(future_exp()),
894            iss: None,
895            aud: None,
896            iat: None,
897            email: None,
898            jti: None,
899            extra: HashMap::new(),
900        };
901
902        let token = make_token(&claims, "any-secret");
903        let err = validator.validate_async(&token).await.unwrap_err();
904        assert!(matches!(err, AuthError::Config(_)));
905    }
906}
907
908#[cfg(test)]
909mod algorithm_family_tests {
910    use super::*;
911    use jsonwebtoken::{encode, EncodingKey, Header};
912    use std::collections::HashMap;
913    use zeroize::Zeroizing;
914
915    /// A throwaway P-256 keypair, generated for this test alone and used
916    /// nowhere else. It is in the source so the test needs no fixtures.
917    const EC_PRIVATE_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgftsCo6PP58Bktonn\nIQQdJB0xhCZr5vaXReMirLL6vfmhRANCAATI63Cp8AxIz8oL4Sfzwp/1r5j8o5v5\nQ2O3+N41eKFkFT/bk1oF8zP6Zz6OaOU7nfw77Pcn+DZz9GFp3ISD9rJK\n-----END PRIVATE KEY-----";
918    const EC_PUBLIC_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyOtwqfAMSM/KC+En88Kf9a+Y/KOb\n+UNjt/jeNXihZBU/25NaBfMz+mc+jmjlO538O+z3J/g2c/RhadyEg/aySg==\n-----END PUBLIC KEY-----";
919
920    fn claims() -> JwtClaims {
921        JwtClaims {
922            sub: Some("ec-user".into()),
923            exp: Some(
924                std::time::SystemTime::now()
925                    .duration_since(std::time::UNIX_EPOCH)
926                    .unwrap()
927                    .as_secs()
928                    + 3600,
929            ),
930            iss: None,
931            aud: None,
932            iat: None,
933            email: None,
934            jti: None,
935            extra: HashMap::new(),
936        }
937    }
938
939    /// ES256 had no key path at all: the only asymmetric branch parsed the
940    /// PEM as RSA, an EC key failed that, and the validator was left with
941    /// no key — so every token from an ES256 issuer got a 401.
942    #[test]
943    fn es256_verifies_a_token_signed_with_its_key() {
944        let config = JwtConfig {
945            algorithm: Some("ES256".into()),
946            public_key_pem: Some(Zeroizing::new(EC_PUBLIC_PEM.to_string())),
947            ..Default::default()
948        };
949        let validator = JwtValidator::new(config).expect("ES256 config must build");
950
951        let token = encode(
952            &Header::new(Algorithm::ES256),
953            &claims(),
954            &EncodingKey::from_ec_pem(EC_PRIVATE_PEM.as_bytes()).unwrap(),
955        )
956        .unwrap();
957
958        let validated = validator.validate(&token).expect("a valid ES256 token");
959        assert_eq!(validated.sub, Some("ec-user".into()));
960    }
961
962    /// The shape that used to fail silently: an asymmetric algorithm with a
963    /// `secret`. It built an HMAC key and rejected every real token, so the
964    /// operator saw 401s with a configuration that looked right.
965    #[test]
966    fn an_asymmetric_algorithm_with_a_secret_is_a_startup_error() {
967        let config = JwtConfig {
968            algorithm: Some("RS256".into()),
969            secret: Some(Zeroizing::new("a-secret-that-is-at-least-32-bytes!".into())),
970            ..Default::default()
971        };
972        let err = JwtValidator::new(config).expect_err("a secret cannot verify an RSA signature");
973        assert!(
974            err.to_string().contains("public key"),
975            "the error must say what to set instead: {err}"
976        );
977    }
978
979    /// The mirror case, so neither family can be configured with the
980    /// other's key material.
981    #[test]
982    fn an_hmac_algorithm_with_a_public_key_is_a_startup_error() {
983        let config = JwtConfig {
984            algorithm: Some("HS256".into()),
985            public_key_pem: Some(Zeroizing::new(EC_PUBLIC_PEM.to_string())),
986            ..Default::default()
987        };
988        assert!(JwtValidator::new(config).is_err());
989    }
990
991    /// Neither kind of material is fine: an OIDC deployment attaches a
992    /// JWKS cache afterwards and resolves each token by its `kid`.
993    #[test]
994    fn no_key_material_is_allowed_for_jwks() {
995        let config = JwtConfig {
996            algorithm: Some("RS256".into()),
997            ..Default::default()
998        };
999        assert!(JwtValidator::new(config).is_ok());
1000    }
1001}