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