Skip to main content

auth_framework/security/
secure_jwt.rs

1use crate::errors::{AuthError, Result};
2use jsonwebtoken::{Algorithm, DecodingKey};
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::time::Duration;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SecureJwtClaims {
9    pub sub: String,
10    pub iss: String,
11    pub aud: String,
12    pub exp: i64,
13    pub nbf: i64,
14    pub iat: i64,
15    pub jti: String,
16    pub scope: String,
17    pub typ: String,
18    pub sid: Option<String>,
19    pub client_id: Option<String>,
20    pub auth_ctx_hash: Option<String>,
21}
22
23#[derive(Debug, Clone)]
24pub struct SecureJwtConfig {
25    pub allowed_algorithms: Vec<Algorithm>,
26    pub required_issuers: HashSet<String>,
27    pub required_audiences: HashSet<String>,
28    pub max_token_lifetime: Duration,
29    pub clock_skew: Duration,
30    pub require_jti: bool,
31    pub validate_nbf: bool,
32    pub allowed_token_types: HashSet<String>,
33    pub require_secure_transport: bool,
34    /// HMAC secret for HS256/HS384/HS512
35    pub jwt_secret: String,
36    /// PEM-encoded RSA public key for RS256/RS384/RS512/PS256/PS384/PS512
37    pub rsa_public_key_pem: Option<String>,
38    /// PEM-encoded EC public key for ES256/ES384
39    pub ec_public_key_pem: Option<String>,
40    /// PEM-encoded Ed25519 public key for EdDSA
41    pub ed_public_key_pem: Option<String>,
42}
43
44impl Default for SecureJwtConfig {
45    fn default() -> Self {
46        // Generate a fresh cryptographically random secret for each instance so that
47        // no default-constructed config ever carries a publicly-known key.
48        //
49        // Callers that need a stable, shared secret (e.g. multi-node clusters that
50        // must validate each other's tokens) must set `jwt_secret` explicitly after
51        // construction.
52        use ring::rand::{SecureRandom, SystemRandom};
53        // SAFETY: CSPRNG failure at initialization is terminal; the framework
54        // cannot operate without entropy.
55        let rng = SystemRandom::new();
56        let mut bytes = [0u8; 32];
57        rng.fill(&mut bytes)
58            .expect("AuthFramework fatal: system CSPRNG unavailable — the operating system cannot provide cryptographic randomness");
59        let jwt_secret = bytes.iter().fold(String::with_capacity(64), |mut s, b| {
60            s.push_str(&format!("{b:02x}"));
61            s
62        });
63
64        let mut allowed_token_types = HashSet::new();
65        allowed_token_types.insert("access".to_string());
66        allowed_token_types.insert("refresh".to_string());
67        allowed_token_types.insert("JARM".to_string());
68
69        let mut required_issuers = HashSet::new();
70        required_issuers.insert("auth-framework".to_string());
71
72        Self {
73            // Only advertise algorithms for which key material is actually present.
74            // Asymmetric algorithms require their corresponding PEM fields to be set;
75            // they are excluded from the default to prevent misconfiguration.
76            allowed_algorithms: vec![Algorithm::HS256],
77            required_issuers,
78            required_audiences: HashSet::new(),
79            max_token_lifetime: Duration::from_secs(3600),
80            clock_skew: Duration::from_secs(30),
81            require_jti: true,
82            validate_nbf: true,
83            allowed_token_types,
84            require_secure_transport: true,
85            jwt_secret,
86            rsa_public_key_pem: None,
87            ec_public_key_pem: None,
88            ed_public_key_pem: None,
89        }
90    }
91}
92
93/// Returns `true` if the algorithm belongs to the HMAC (symmetric) family.
94fn is_hmac_algorithm(alg: Algorithm) -> bool {
95    matches!(alg, Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512)
96}
97
98/// Validates JWT tokens with configurable algorithm support and in-memory revocation.
99///
100/// # Revocation Architecture
101///
102/// `SecureJwtValidator` maintains an **in-memory** revocation list (`HashMap<JTI, SystemTime>`)
103/// protected by a `Mutex`. This list is **lost on process restart** and is intended as a
104/// supplementary fast-path cache — not as a durable revocation store.
105///
106/// For production deployments, durable revocation should be handled by the storage-backed
107/// layer in the API module (see `src/api/auth.rs`), which persists revoked JTIs in the
108/// configured KV / database backend.
109///
110/// To bridge both layers, callers can register an optional `on_revoke` callback via
111/// [`SecureJwtValidator::set_on_revoke`]. When set, every call to [`revoke_token`] will
112/// first insert into the in-memory map and then invoke the callback with the JTI string,
113/// allowing the caller to persist the revocation to external storage without changing the
114/// existing API surface.
115///
116/// # Size Limits
117///
118/// [`cleanup_revoked_tokens`] enforces a hard cap of 10 000 entries and time-based eviction
119/// to prevent unbounded memory growth.
120
121impl SecureJwtConfig {
122    /// Create a new builder with secure default configurations.
123    pub fn builder() -> SecureJwtConfigBuilder {
124        SecureJwtConfigBuilder::default()
125    }
126}
127
128/// A builder for SecureJwtConfig
129pub struct SecureJwtConfigBuilder {
130    config: SecureJwtConfig,
131}
132
133impl Default for SecureJwtConfigBuilder {
134    fn default() -> Self {
135        Self {
136            config: SecureJwtConfig::default(),
137        }
138    }
139}
140
141impl SecureJwtConfigBuilder {
142    /// Allow a specific JSON Web Signature algorithm
143    pub fn with_algorithm(mut self, algo: Algorithm) -> Self {
144        self.config.allowed_algorithms.push(algo);
145        self
146    }
147
148    /// Set the allowed algorithms, replacing any existing
149    pub fn with_algorithms(mut self, algos: Vec<Algorithm>) -> Self {
150        self.config.allowed_algorithms = algos;
151        self
152    }
153
154    /// Require a specific issuer string
155    pub fn require_issuer(mut self, issuer: impl Into<String>) -> Self {
156        self.config.required_issuers.insert(issuer.into());
157        self
158    }
159
160    /// Require a specific audience string
161    pub fn require_audience(mut self, audience: impl Into<String>) -> Self {
162        self.config.required_audiences.insert(audience.into());
163        self
164    }
165
166    /// Set the maximum allowed lifetime of a token before it is rejected
167    pub fn with_max_lifetime(mut self, lifetime: Duration) -> Self {
168        self.config.max_token_lifetime = lifetime;
169        self
170    }
171
172    /// Set the allowed clock skew when evaluating timestamps
173    pub fn with_clock_skew(mut self, skew: Duration) -> Self {
174        self.config.clock_skew = skew;
175        self
176    }
177
178    /// Set whether a JWT ID (jti) claim is required
179    pub fn require_jti(mut self, require: bool) -> Self {
180        self.config.require_jti = require;
181        self
182    }
183
184    /// Set the HMAC signing secret (required for symmetric signing operations)
185    pub fn with_secret(mut self, secret: impl Into<String>) -> Self {
186        self.config.jwt_secret = secret.into();
187        self
188    }
189
190    /// Build the SecureJwtConfig
191    pub fn build(self) -> SecureJwtConfig {
192        self.config
193    }
194}
195
196pub struct SecureJwtValidator {
197    config: SecureJwtConfig,
198    /// Maps JTI → insertion timestamp so we can evict by age in `cleanup_revoked_tokens`.
199    ///
200    /// **In-memory only** — entries do not survive process restarts. See the struct-level
201    /// documentation for the recommended dual-layer revocation approach.
202    revoked_tokens: std::sync::Mutex<std::collections::HashMap<String, std::time::SystemTime>>,
203    /// Optional callback invoked with the JTI string each time a token is revoked.
204    ///
205    /// Use [`set_on_revoke`] to register a closure that persists the revocation to durable
206    /// storage (database, KV store, etc.). The callback is invoked **after** the in-memory
207    /// insertion succeeds.
208    on_revoke: std::sync::Mutex<Option<Box<dyn Fn(&str) + Send + Sync>>>,
209}
210
211impl std::fmt::Debug for SecureJwtValidator {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("SecureJwtValidator")
214            .field("config", &self.config)
215            .field("revoked_tokens", &self.revoked_tokens)
216            .field(
217                "on_revoke",
218                &self.on_revoke.lock().ok().map(|g| g.is_some()),
219            )
220            .finish()
221    }
222}
223
224impl SecureJwtValidator {
225    pub fn new(config: SecureJwtConfig) -> Result<Self> {
226        // Validate that every allowed algorithm has the required key material.
227        let has_hmac = config
228            .allowed_algorithms
229            .iter()
230            .any(|a| is_hmac_algorithm(*a));
231        let has_rsa = config.allowed_algorithms.iter().any(|a| {
232            matches!(
233                a,
234                Algorithm::RS256
235                    | Algorithm::RS384
236                    | Algorithm::RS512
237                    | Algorithm::PS256
238                    | Algorithm::PS384
239                    | Algorithm::PS512
240            )
241        });
242        let has_ec = config
243            .allowed_algorithms
244            .iter()
245            .any(|a| matches!(a, Algorithm::ES256 | Algorithm::ES384));
246        let has_eddsa = config
247            .allowed_algorithms
248            .iter()
249            .any(|a| matches!(a, Algorithm::EdDSA));
250
251        if has_hmac {
252            #[cfg(not(test))]
253            if config.jwt_secret.len() < 32 {
254                return Err(AuthError::Configuration {
255                    message: "SecureJwtConfig::jwt_secret must be at least 32 characters \
256                              when HMAC algorithms are enabled"
257                        .to_string(),
258                    help: Some(
259                        "Provide a cryptographically random secret unique to your deployment"
260                            .to_string(),
261                    ),
262                    docs_url: None,
263                    source: None,
264                    suggested_fix: None,
265                });
266            }
267        }
268        if has_rsa && config.rsa_public_key_pem.is_none() {
269            return Err(AuthError::Configuration {
270                message: "SecureJwtConfig::rsa_public_key_pem must be set when RSA/PS algorithms are enabled".to_string(),
271                help: Some("Set rsa_public_key_pem in SecureJwtConfig".to_string()),
272                docs_url: None,
273                source: None,
274                suggested_fix: None,
275            });
276        }
277        if has_ec && config.ec_public_key_pem.is_none() {
278            return Err(AuthError::Configuration {
279                message:
280                    "SecureJwtConfig::ec_public_key_pem must be set when EC algorithms are enabled"
281                        .to_string(),
282                help: Some("Set ec_public_key_pem in SecureJwtConfig".to_string()),
283                docs_url: None,
284                source: None,
285                suggested_fix: None,
286            });
287        }
288        if has_eddsa && config.ed_public_key_pem.is_none() {
289            return Err(AuthError::Configuration {
290                message: "SecureJwtConfig::ed_public_key_pem must be set when EdDSA is enabled"
291                    .to_string(),
292                help: Some("Set ed_public_key_pem in SecureJwtConfig".to_string()),
293                docs_url: None,
294                source: None,
295                suggested_fix: None,
296            });
297        }
298
299        Ok(Self {
300            config,
301            revoked_tokens: std::sync::Mutex::new(std::collections::HashMap::new()),
302            on_revoke: std::sync::Mutex::new(None),
303        })
304    }
305
306    /// Register an optional callback that is invoked with the JTI every time
307    /// [`revoke_token`] is called.
308    ///
309    /// This allows callers to persist revocations to durable storage (database,
310    /// KV store, etc.) without changing the existing validation or revocation API.
311    ///
312    /// # Example
313    ///
314    /// ```rust,ignore
315    /// validator.set_on_revoke(|jti| {
316    ///     // Persist to your storage backend
317    ///     storage.insert_revoked_jti(jti);
318    /// });
319    /// ```
320    pub fn set_on_revoke<F>(&self, callback: F)
321    where
322        F: Fn(&str) + Send + Sync + 'static,
323    {
324        let mut guard = match self.on_revoke.lock() {
325            Ok(g) => g,
326            Err(poisoned) => poisoned.into_inner(),
327        };
328        *guard = Some(Box::new(callback));
329    }
330
331    /// Get HMAC decoding key for backward-compatible call sites.
332    ///
333    /// Prefer `validate` which handles key selection automatically.
334    pub fn get_decoding_key(&self) -> jsonwebtoken::DecodingKey {
335        jsonwebtoken::DecodingKey::from_secret(self.config.jwt_secret.as_bytes())
336    }
337
338    /// Get HMAC encoding key for signing JWTs.
339    pub fn get_encoding_key(&self) -> jsonwebtoken::EncodingKey {
340        jsonwebtoken::EncodingKey::from_secret(self.config.jwt_secret.as_bytes())
341    }
342
343    /// Select the appropriate [`DecodingKey`] for the given algorithm.
344    fn decoding_key_for_algorithm(&self, alg: Algorithm) -> Result<DecodingKey> {
345        match alg {
346            Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512 => {
347                Ok(DecodingKey::from_secret(self.config.jwt_secret.as_bytes()))
348            }
349            Algorithm::RS256
350            | Algorithm::RS384
351            | Algorithm::RS512
352            | Algorithm::PS256
353            | Algorithm::PS384
354            | Algorithm::PS512 => {
355                let pem = self.config.rsa_public_key_pem.as_deref().ok_or_else(|| {
356                    AuthError::Configuration {
357                        message: "RSA public key PEM not configured".to_string(),
358                        help: Some(
359                            "Set rsa_public_key_pem in SecureJwtConfig for RSA/PS algorithms"
360                                .to_string(),
361                        ),
362                        docs_url: None,
363                        source: None,
364                        suggested_fix: None,
365                    }
366                })?;
367                DecodingKey::from_rsa_pem(pem.as_bytes()).map_err(|e| AuthError::Configuration {
368                    message: format!("Invalid RSA public key PEM: {e}"),
369                    help: None,
370                    docs_url: None,
371                    source: None,
372                    suggested_fix: None,
373                })
374            }
375            Algorithm::ES256 | Algorithm::ES384 => {
376                let pem = self.config.ec_public_key_pem.as_deref().ok_or_else(|| {
377                    AuthError::Configuration {
378                        message: "EC public key PEM not configured".to_string(),
379                        help: Some(
380                            "Set ec_public_key_pem in SecureJwtConfig for EC algorithms"
381                                .to_string(),
382                        ),
383                        docs_url: None,
384                        source: None,
385                        suggested_fix: None,
386                    }
387                })?;
388                DecodingKey::from_ec_pem(pem.as_bytes()).map_err(|e| AuthError::Configuration {
389                    message: format!("Invalid EC public key PEM: {e}"),
390                    help: None,
391                    docs_url: None,
392                    source: None,
393                    suggested_fix: None,
394                })
395            }
396            Algorithm::EdDSA => {
397                let pem = self.config.ed_public_key_pem.as_deref().ok_or_else(|| {
398                    AuthError::Configuration {
399                        message: "Ed25519 public key PEM not configured".to_string(),
400                        help: Some(
401                            "Set ed_public_key_pem in SecureJwtConfig for EdDSA".to_string(),
402                        ),
403                        docs_url: None,
404                        source: None,
405                        suggested_fix: None,
406                    }
407                })?;
408                DecodingKey::from_ed_pem(pem.as_bytes()).map_err(|e| AuthError::Configuration {
409                    message: format!("Invalid Ed25519 public key PEM: {e}"),
410                    help: None,
411                    docs_url: None,
412                    source: None,
413                    suggested_fix: None,
414                })
415            }
416        }
417    }
418
419    /// Validate a JWT, automatically selecting the key based on the token header algorithm.
420    ///
421    /// This is the preferred entry point. It:
422    /// 1. Decodes the JWT header to determine the claimed algorithm.
423    /// 2. Rejects the token immediately if the algorithm is not in `allowed_algorithms`.
424    /// 3. Selects the correct decoding key for the algorithm family.
425    /// 4. Validates the signature **and** all standard claims (exp, nbf, iss, aud).
426    /// 5. Performs additional checks: revocation, max lifetime, JTI presence, token type.
427    pub fn validate(&self, token: &str) -> Result<SecureJwtClaims> {
428        // 1. Decode header (no signature verification yet).
429        let header = jsonwebtoken::decode_header(token)
430            .map_err(|e| AuthError::Unauthorized(format!("Invalid JWT header: {e}")))?;
431
432        // 2. Reject algorithms not in the configured allow-list.
433        if !self.config.allowed_algorithms.contains(&header.alg) {
434            return Err(AuthError::Unauthorized(format!(
435                "Token algorithm {:?} is not permitted; allowed: {:?}",
436                header.alg, self.config.allowed_algorithms
437            )));
438        }
439
440        // 3. Select the decoding key for this algorithm family.
441        let decoding_key = self.decoding_key_for_algorithm(header.alg)?;
442
443        // 4. Build validation rules — delegate standard claim checks to jsonwebtoken.
444        let mut validation = jsonwebtoken::Validation::new(header.alg);
445        validation.algorithms = self.config.allowed_algorithms.clone();
446        validation.leeway = self.config.clock_skew.as_secs();
447
448        // Expiration: always enforced.
449        validation.validate_exp = true;
450
451        // Not-before: honour config.
452        validation.validate_nbf = self.config.validate_nbf;
453
454        // Audience: enforce if the config specifies required audiences.
455        if !self.config.required_audiences.is_empty() {
456            validation.set_audience(
457                &self
458                    .config
459                    .required_audiences
460                    .iter()
461                    .collect::<Vec<&String>>(),
462            );
463        } else {
464            validation.validate_aud = false;
465        }
466
467        // Issuer: enforce if the config specifies required issuers.
468        if !self.config.required_issuers.is_empty() {
469            validation.set_issuer(
470                &self
471                    .config
472                    .required_issuers
473                    .iter()
474                    .collect::<Vec<&String>>(),
475            );
476        }
477
478        // 5. Decode & verify signature + standard claims.
479        let token_data = jsonwebtoken::decode::<SecureJwtClaims>(token, &decoding_key, &validation)
480            .map_err(|e| AuthError::Unauthorized(format!("JWT validation failed: {e}")))?;
481
482        let claims = token_data.claims;
483
484        // 6. Additional custom validations.
485
486        // Revocation check.
487        if self.is_token_revoked(&claims.jti)? {
488            return Err(AuthError::Unauthorized("Token is revoked".to_string()));
489        }
490
491        // Max token lifetime.
492        let token_lifetime = claims.exp.saturating_sub(claims.iat);
493        if token_lifetime > 0 && (token_lifetime as u64) > self.config.max_token_lifetime.as_secs()
494        {
495            return Err(AuthError::Unauthorized(format!(
496                "Token lifetime ({token_lifetime}s) exceeds maximum allowed ({}s)",
497                self.config.max_token_lifetime.as_secs()
498            )));
499        }
500
501        // JTI presence.
502        if self.config.require_jti && claims.jti.is_empty() {
503            return Err(AuthError::Unauthorized(
504                "Token missing required JTI claim".to_string(),
505            ));
506        }
507
508        // Token type restriction.
509        if !self.config.allowed_token_types.is_empty() && !claims.typ.is_empty() {
510            if !self.config.allowed_token_types.contains(&claims.typ) {
511                return Err(AuthError::Unauthorized(format!(
512                    "Token type '{}' is not permitted",
513                    claims.typ
514                )));
515            }
516        }
517
518        Ok(claims)
519    }
520
521    /// Legacy validation entry point that accepts a caller-supplied decoding key.
522    ///
523    /// Prefer [`validate`] which handles algorithm checking and key selection internally.
524    /// This method still enforces the full allow-list and all claim checks.
525    pub fn validate_token(
526        &self,
527        token: &str,
528        decoding_key: &DecodingKey,
529    ) -> Result<SecureJwtClaims> {
530        let header = jsonwebtoken::decode_header(token)
531            .map_err(|e| AuthError::Unauthorized(format!("Invalid JWT header: {e}")))?;
532
533        if !self.config.allowed_algorithms.contains(&header.alg) {
534            return Err(AuthError::Unauthorized(format!(
535                "Token algorithm {:?} is not permitted; allowed: {:?}",
536                header.alg, self.config.allowed_algorithms
537            )));
538        }
539
540        let mut validation = jsonwebtoken::Validation::new(header.alg);
541        validation.algorithms = self.config.allowed_algorithms.clone();
542        validation.leeway = self.config.clock_skew.as_secs();
543        validation.validate_exp = true;
544        validation.validate_nbf = self.config.validate_nbf;
545
546        if !self.config.required_audiences.is_empty() {
547            validation.set_audience(
548                &self
549                    .config
550                    .required_audiences
551                    .iter()
552                    .collect::<Vec<&String>>(),
553            );
554        } else {
555            validation.validate_aud = false;
556        }
557
558        if !self.config.required_issuers.is_empty() {
559            validation.set_issuer(
560                &self
561                    .config
562                    .required_issuers
563                    .iter()
564                    .collect::<Vec<&String>>(),
565            );
566        }
567
568        let token_data = jsonwebtoken::decode::<SecureJwtClaims>(token, decoding_key, &validation)
569            .map_err(|e| AuthError::Unauthorized(format!("JWT validation failed: {e}")))?;
570
571        let claims = token_data.claims;
572
573        if self.is_token_revoked(&claims.jti)? {
574            return Err(AuthError::Unauthorized("Token is revoked".to_string()));
575        }
576
577        let token_lifetime = claims.exp.saturating_sub(claims.iat);
578        if token_lifetime > 0 && (token_lifetime as u64) > self.config.max_token_lifetime.as_secs()
579        {
580            return Err(AuthError::Unauthorized(format!(
581                "Token lifetime ({token_lifetime}s) exceeds maximum allowed ({}s)",
582                self.config.max_token_lifetime.as_secs()
583            )));
584        }
585
586        if self.config.require_jti && claims.jti.is_empty() {
587            return Err(AuthError::Unauthorized(
588                "Token missing required JTI claim".to_string(),
589            ));
590        }
591
592        if !self.config.allowed_token_types.is_empty() && !claims.typ.is_empty() {
593            if !self.config.allowed_token_types.contains(&claims.typ) {
594                return Err(AuthError::Unauthorized(format!(
595                    "Token type '{}' is not permitted",
596                    claims.typ
597                )));
598            }
599        }
600
601        Ok(claims)
602    }
603
604    /// Check whether `jti` appears in the **in-memory** revocation list.
605    ///
606    /// This only consults the local cache. For a complete revocation check that
607    /// also queries durable storage, combine this with the storage-backed lookup
608    /// in the API layer (`src/api/auth.rs`).
609    pub fn is_token_revoked(&self, jti: &str) -> Result<bool> {
610        let revoked_tokens = self.revoked_tokens.lock().map_err(|_| {
611            AuthError::internal("Lock poisoned — a prior thread panicked while holding this lock")
612        })?;
613        Ok(revoked_tokens.contains_key(jti))
614    }
615
616    /// Revoke a token by its JTI.
617    ///
618    /// The JTI is inserted into the **in-memory** revocation map. If an
619    /// [`on_revoke`](Self::set_on_revoke) callback has been registered, it is
620    /// invoked with the JTI after the in-memory insertion, allowing durable
621    /// persistence without changing this method's signature.
622    ///
623    /// **Note:** Without a registered `on_revoke` callback, revocations are
624    /// volatile and will be lost on process restart.
625    pub fn revoke_token(&self, jti: &str) -> Result<()> {
626        {
627            let mut revoked_tokens = self.revoked_tokens.lock().map_err(|_| {
628                AuthError::internal(
629                    "Lock poisoned — a prior thread panicked while holding this lock",
630                )
631            })?;
632            revoked_tokens.insert(jti.to_string(), std::time::SystemTime::now());
633        }
634        // Invoke the persistence callback outside the revoked_tokens lock to
635        // avoid holding two locks simultaneously.
636        if let Some(ref cb) = *self.on_revoke.lock().map_err(|_| {
637            AuthError::internal("Lock poisoned — a prior thread panicked while holding this lock")
638        })? {
639            cb(jti);
640        }
641        Ok(())
642    }
643
644    /// Remove revoked token entries that are older than `expired_cutoff`.
645    ///
646    /// This prevents unbounded memory growth in long-running deployments.  Callers should
647    /// pass a cutoff equal to `now − max_token_lifetime` so that every entry that could
648    /// still be used by a live token is preserved, while entries that can only correspond
649    /// to already-expired tokens are discarded.
650    ///
651    /// An additional size cap (10,000 entries) is enforced after time-based eviction:
652    /// if the map still exceeds the cap the oldest 25 % of entries are removed.
653    pub fn cleanup_revoked_tokens(&self, expired_cutoff: std::time::SystemTime) -> Result<()> {
654        const MAX_REVOKED_TOKENS: usize = 10_000;
655        let mut revoked_tokens = self.revoked_tokens.lock().map_err(|_| {
656            AuthError::internal("Lock poisoned — a prior thread panicked while holding this lock")
657        })?;
658
659        // Phase 1: remove entries whose insertion time predates the cutoff — these
660        // correspond to JWTs that would have expired naturally already.
661        revoked_tokens.retain(|_, inserted_at| *inserted_at >= expired_cutoff);
662
663        // Phase 2: hard size cap — if phase 1 was not enough (e.g. very short cleanup
664        // interval or very long token lifetime), evict the oldest 25 % of remaining entries.
665        if revoked_tokens.len() > MAX_REVOKED_TOKENS {
666            let target_len = MAX_REVOKED_TOKENS * 3 / 4;
667            let mut by_age: Vec<(String, std::time::SystemTime)> = revoked_tokens.drain().collect();
668            by_age.sort_unstable_by_key(|(_, t)| *t);
669            // Re-insert only the newest entries.
670            for (jti, inserted_at) in by_age.into_iter().rev().take(target_len) {
671                revoked_tokens.insert(jti, inserted_at);
672            }
673            tracing::warn!(
674                "Revoked token list exceeded {} entries; oldest entries were evicted.",
675                MAX_REVOKED_TOKENS
676            );
677        }
678
679        Ok(())
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use jsonwebtoken::{Algorithm, EncodingKey, Header};
687
688    fn test_config() -> SecureJwtConfig {
689        SecureJwtConfig {
690            jwt_secret: "a]test_secret_that_is_longer_than_32_chars_for_security!".to_string(),
691            ..SecureJwtConfig::default()
692        }
693    }
694
695    fn issue_token(config: &SecureJwtConfig, claims: &SecureJwtClaims) -> String {
696        let key = EncodingKey::from_secret(config.jwt_secret.as_bytes());
697        jsonwebtoken::encode(&Header::new(Algorithm::HS256), claims, &key).unwrap()
698    }
699
700    fn valid_claims() -> SecureJwtClaims {
701        let now = chrono::Utc::now().timestamp();
702        SecureJwtClaims {
703            sub: "user123".to_string(),
704            iss: "auth-framework".to_string(),
705            aud: "test".to_string(),
706            exp: now + 600,
707            nbf: now - 10,
708            iat: now,
709            jti: uuid::Uuid::new_v4().to_string(),
710            scope: "read".to_string(),
711            typ: "access".to_string(),
712            sid: None,
713            client_id: None,
714            auth_ctx_hash: None,
715        }
716    }
717
718    #[test]
719    fn test_default_config_generates_random_secret() {
720        let c1 = SecureJwtConfig::default();
721        let c2 = SecureJwtConfig::default();
722        assert_ne!(c1.jwt_secret, c2.jwt_secret);
723        assert!(c1.jwt_secret.len() >= 32);
724    }
725
726    #[test]
727    fn test_builder_fluent_api() {
728        let config = SecureJwtConfig::builder()
729            .with_secret("a]test_secret_that_is_longer_than_32_chars_for_security!")
730            .require_issuer("my-issuer")
731            .require_audience("my-aud")
732            .with_max_lifetime(Duration::from_secs(7200))
733            .with_clock_skew(Duration::from_secs(60))
734            .require_jti(false)
735            .build();
736
737        assert!(config.required_issuers.contains("my-issuer"));
738        assert!(config.required_audiences.contains("my-aud"));
739        assert_eq!(config.max_token_lifetime, Duration::from_secs(7200));
740        assert_eq!(config.clock_skew, Duration::from_secs(60));
741        assert!(!config.require_jti);
742    }
743
744    #[test]
745    fn test_validate_valid_token() {
746        let config = test_config();
747        let claims = valid_claims();
748        let token = issue_token(&config, &claims);
749        let validator = SecureJwtValidator::new(config).unwrap();
750        let result = validator.validate(&token).unwrap();
751        assert_eq!(result.sub, "user123");
752        assert_eq!(result.iss, "auth-framework");
753    }
754
755    #[test]
756    fn test_validate_rejects_expired_token() {
757        let config = test_config();
758        let mut claims = valid_claims();
759        claims.exp = chrono::Utc::now().timestamp() - 3600;
760        claims.iat = claims.exp - 600;
761        let token = issue_token(&config, &claims);
762        let validator = SecureJwtValidator::new(config).unwrap();
763        assert!(validator.validate(&token).is_err());
764    }
765
766    #[test]
767    fn test_validate_rejects_wrong_issuer() {
768        let config = test_config();
769        let mut claims = valid_claims();
770        claims.iss = "evil-issuer".to_string();
771        let token = issue_token(&config, &claims);
772        let validator = SecureJwtValidator::new(config).unwrap();
773        assert!(validator.validate(&token).is_err());
774    }
775
776    #[test]
777    fn test_revoke_and_check() {
778        let config = test_config();
779        let validator = SecureJwtValidator::new(config).unwrap();
780        let jti = "test-jti-123";
781        assert!(!validator.is_token_revoked(jti).unwrap());
782        validator.revoke_token(jti).unwrap();
783        assert!(validator.is_token_revoked(jti).unwrap());
784    }
785
786    #[test]
787    fn test_revoked_token_rejected() {
788        let config = test_config();
789        let claims = valid_claims();
790        let jti = claims.jti.clone();
791        let token = issue_token(&config, &claims);
792        let validator = SecureJwtValidator::new(config).unwrap();
793        validator.revoke_token(&jti).unwrap();
794        assert!(validator.validate(&token).is_err());
795    }
796
797    #[test]
798    fn test_cleanup_removes_old_entries() {
799        let config = test_config();
800        let validator = SecureJwtValidator::new(config).unwrap();
801        validator.revoke_token("old-jti").unwrap();
802        // Cleanup with a cutoff in the future removes everything
803        let future = std::time::SystemTime::now() + Duration::from_secs(3600);
804        validator.cleanup_revoked_tokens(future).unwrap();
805        assert!(!validator.is_token_revoked("old-jti").unwrap());
806    }
807
808    #[test]
809    fn test_on_revoke_callback() {
810        let config = test_config();
811        let validator = SecureJwtValidator::new(config).unwrap();
812        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
813        let captured_clone = captured.clone();
814        validator.set_on_revoke(move |jti| {
815            captured_clone.lock().unwrap().push(jti.to_string());
816        });
817        validator.revoke_token("cb-jti-1").unwrap();
818        validator.revoke_token("cb-jti-2").unwrap();
819        let jtis = captured.lock().unwrap();
820        assert_eq!(jtis.len(), 2);
821        assert!(jtis.contains(&"cb-jti-1".to_string()));
822        assert!(jtis.contains(&"cb-jti-2".to_string()));
823    }
824
825    #[test]
826    fn test_rejects_disallowed_algorithm() {
827        let config = test_config();
828        let claims = valid_claims();
829        // Sign with HS384 but config only allows HS256
830        let key = EncodingKey::from_secret(config.jwt_secret.as_bytes());
831        let token =
832            jsonwebtoken::encode(&Header::new(Algorithm::HS384), &claims, &key).unwrap();
833        let validator = SecureJwtValidator::new(config).unwrap();
834        assert!(validator.validate(&token).is_err());
835    }
836
837    #[test]
838    fn test_rejects_excessive_lifetime() {
839        let mut config = test_config();
840        config.max_token_lifetime = Duration::from_secs(300);
841        let mut claims = valid_claims();
842        let now = chrono::Utc::now().timestamp();
843        claims.iat = now;
844        claims.exp = now + 600; // 10 min > 5 min max
845        let token = issue_token(&config, &claims);
846        let validator = SecureJwtValidator::new(config).unwrap();
847        assert!(validator.validate(&token).is_err());
848    }
849
850    #[test]
851    fn test_missing_rsa_key_rejected() {
852        let mut config = test_config();
853        config.allowed_algorithms = vec![Algorithm::RS256];
854        let result = SecureJwtValidator::new(config);
855        assert!(result.is_err());
856    }
857}