From 50d653de40c69e704ecd107fa60298a0294b024a Mon Sep 17 00:00:00 2001 From: Kiran Kumar Pradhan Date: Thu, 28 May 2026 01:24:12 +0530 Subject: [PATCH 1/4] fix(jans-cedarling): honor uncapped token cache TTL Fix CEDARLING_TOKEN_CACHE_MAX_TTL=0 so SparkV receives an internal TTL ceiling that does not reject entries using the token exp claim. Fixes #14154 Signed-off-by: Kiran Kumar Pradhan --- .../cedarling/src/jwt/token_cache.rs | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/jans-cedarling/cedarling/src/jwt/token_cache.rs b/jans-cedarling/cedarling/src/jwt/token_cache.rs index 2811c865249..1ea770f77ca 100644 --- a/jans-cedarling/cedarling/src/jwt/token_cache.rs +++ b/jans-cedarling/cedarling/src/jwt/token_cache.rs @@ -15,6 +15,17 @@ use crate::jwt::token::Token; use crate::jwt::validation::TokenKind; use crate::log::{BaseLogEntry, LogEntry, LogWriter, Logger}; +fn sparkv_max_ttl(max_ttl: usize) -> Duration { + if max_ttl == 0 { + return Duration::MAX; + } + + i64::try_from(max_ttl) + .ok() + .and_then(Duration::try_seconds) + .unwrap_or(Duration::MAX) +} + /// A dedicated cache for storing validated JWT tokens. /// /// The cache uses a thread-safe key-value store with automatic expiration @@ -60,7 +71,7 @@ impl TokenCache { ) -> Self { Self { cache: Arc::new(RwLock::new(SparKV::with_config(Config { - max_ttl: Duration::seconds(i64::try_from(max_ttl).unwrap_or_default()), + max_ttl: sparkv_max_ttl(max_ttl), max_items: capacity, earliest_expiration_eviction, ..Default::default() @@ -252,3 +263,57 @@ impl IndexKey { self.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::jwt::token::TokenClaims; + use serde_json::{Value, json}; + use std::collections::HashMap; + + fn token_cache(max_ttl: usize) -> TokenCache { + TokenCache::new(max_ttl, 100, true, None, Arc::new(MetricsCollector::new(0))) + } + + fn token(claims: HashMap) -> Arc { + Arc::new(Token::new("test", TokenClaims::from(claims), None)) + } + + fn token_with_exp(now: DateTime, duration_secs: i64) -> Arc { + token(HashMap::from([( + "exp".to_string(), + json!(now.timestamp() + duration_secs), + )])) + } + + #[test] + fn max_ttl_zero_uses_token_expiration() { + let now = Utc::now(); + let cache = token_cache(0); + let token = token_with_exp(now, 3600); + + cache.save(&TokenKind::StatusList, "jwt", token, now); + + assert!(cache.find(&TokenKind::StatusList, "jwt").is_some()); + } + + #[test] + fn max_ttl_zero_does_not_cache_token_without_exp() { + let now = Utc::now(); + let cache = token_cache(0); + let token = token(HashMap::new()); + + cache.save(&TokenKind::StatusList, "jwt", token, now); + + assert!(cache.find(&TokenKind::StatusList, "jwt").is_none()); + } + + #[test] + fn positive_max_ttl_caps_token_expiration() { + let now = Utc::now(); + let cache = token_cache(5); + let token = token_with_exp(now, 3600); + + assert_eq!(cache.cache_duration(&token, now), Some(5)); + } +} From 2503d4cc154578366c454e5c49ec81811fdcf7d6 Mon Sep 17 00:00:00 2001 From: Kiran Kumar Pradhan Date: Fri, 29 May 2026 18:12:22 +0530 Subject: [PATCH 2/4] fix(jans-cedarling): disable token cache for zero max ttl Update CEDARLING_TOKEN_CACHE_MAX_TTL=0 handling to bypass token cache reads and writes entirely, avoiding SparkV ttl-too-long warnings and unbounded caching. Signed-off-by: Kiran Kumar Pradhan --- .../src/bootstrap_config/jwt_config.rs | 2 +- .../src/bootstrap_config/raw_config/config.rs | 3 +- .../cedarling/src/jwt/token_cache.rs | 51 ++++++++++--------- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs b/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs index 2dafa43ee8a..d854c9113f2 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/jwt_config.rs @@ -40,7 +40,7 @@ pub struct JwtConfig { /// Only tokens signed with algorithms in this list can be valid. pub signature_algorithms_supported: HashSet, /// Maximum TTL (in seconds) for cached tokens. - /// Zero means no TTL limit is applied. + /// Zero disables the token cache entirely. /// /// Defaults to [`Self::DEFAULT_TOKEN_CACHE_MAX_TTL_SECS`] (5 seconds): small /// enough that revocation / status-list changes are picked up promptly while diff --git a/jans-cedarling/cedarling/src/bootstrap_config/raw_config/config.rs b/jans-cedarling/cedarling/src/bootstrap_config/raw_config/config.rs index 40541b9f939..d749b79d1b9 100644 --- a/jans-cedarling/cedarling/src/bootstrap_config/raw_config/config.rs +++ b/jans-cedarling/cedarling/src/bootstrap_config/raw_config/config.rs @@ -290,8 +290,7 @@ pub struct BootstrapConfigRaw { /// /// - `> 0`: cap each entry's TTL at this value. Also used as the TTL for /// tokens that do not carry an `exp` claim. - /// - `0`: disables the cap. The entry TTL is taken from the token's `exp` - /// claim; tokens without `exp` are not cached at all. + /// - `0`: disables the token cache entirely. /// /// Default: `5` seconds — small enough to pick up revocation / status-list /// changes quickly, large enough to amortise repeated requests for the diff --git a/jans-cedarling/cedarling/src/jwt/token_cache.rs b/jans-cedarling/cedarling/src/jwt/token_cache.rs index 1ea770f77ca..50bfd39b895 100644 --- a/jans-cedarling/cedarling/src/jwt/token_cache.rs +++ b/jans-cedarling/cedarling/src/jwt/token_cache.rs @@ -8,23 +8,12 @@ use sparkv::{Config, SparKV}; use std::hash::Hash; use std::sync::{Arc, RwLock}; -use crate::LogLevel; use crate::authz::metrics::MetricsCollector; use crate::common::issuer_utils::IssClaim; use crate::jwt::token::Token; use crate::jwt::validation::TokenKind; use crate::log::{BaseLogEntry, LogEntry, LogWriter, Logger}; - -fn sparkv_max_ttl(max_ttl: usize) -> Duration { - if max_ttl == 0 { - return Duration::MAX; - } - - i64::try_from(max_ttl) - .ok() - .and_then(Duration::try_seconds) - .unwrap_or(Duration::MAX) -} +use crate::LogLevel; /// A dedicated cache for storing validated JWT tokens. /// @@ -60,8 +49,7 @@ impl TokenCache { /// `max_ttl` semantics: /// - `> 0`: caps each entry's TTL at `max_ttl`. Also used as the TTL when /// the token has no `exp` claim. - /// - `0`: disables the cap. The entry TTL is taken from the token's `exp` - /// claim; tokens without `exp` are not cached. + /// - `0`: disables the token cache entirely. pub(crate) fn new( max_ttl: usize, capacity: usize, @@ -71,7 +59,7 @@ impl TokenCache { ) -> Self { Self { cache: Arc::new(RwLock::new(SparKV::with_config(Config { - max_ttl: sparkv_max_ttl(max_ttl), + max_ttl: Duration::seconds(i64::try_from(max_ttl).unwrap_or_default()), max_items: capacity, earliest_expiration_eviction, ..Default::default() @@ -99,6 +87,11 @@ impl TokenCache { /// Returns `Some(Arc)` if the token is found in cache and not expired, /// otherwise returns `None`. pub(crate) fn find(&self, kind: &TokenKind, jwt: &str) -> Option> { + if self.max_ttl == 0 { + self.metrics.record_cache_miss(); + return None; + } + let key = hash_jwt_token(kind, jwt); let result = self .cache @@ -120,10 +113,12 @@ impl TokenCache { /// 1. If the token has a valid `exp` claim, the entry TTL is the time until /// expiration (clamped by `max_ttl` when it is > 0). /// 2. If the token has no `exp` claim and `max_ttl` is > 0, `max_ttl` is used. - /// 3. If the token has no `exp` claim and `max_ttl` is 0, the token is **not - /// cached at all** (matches the documented behaviour of - /// `CEDARLING_TOKEN_CACHE_MAX_TTL = 0`). + /// 3. If `max_ttl` is 0, the token cache is disabled and no token is cached. pub(crate) fn save(&self, kind: &TokenKind, jwt: &str, token: Arc, now: DateTime) { + if self.max_ttl == 0 { + return; + } + if TokenCache::check_token_expired(&token, now) { // token is expired, no need to save it return; @@ -268,7 +263,7 @@ impl IndexKey { mod tests { use super::*; use crate::jwt::token::TokenClaims; - use serde_json::{Value, json}; + use serde_json::{json, Value}; use std::collections::HashMap; fn token_cache(max_ttl: usize) -> TokenCache { @@ -287,14 +282,17 @@ mod tests { } #[test] - fn max_ttl_zero_uses_token_expiration() { + fn max_ttl_zero_disables_cache_for_token_with_exp() { let now = Utc::now(); let cache = token_cache(0); let token = token_with_exp(now, 3600); cache.save(&TokenKind::StatusList, "jwt", token, now); - assert!(cache.find(&TokenKind::StatusList, "jwt").is_some()); + assert!( + cache.find(&TokenKind::StatusList, "jwt").is_none(), + "max_ttl=0 should disable token cache even when the token has exp" + ); } #[test] @@ -305,7 +303,10 @@ mod tests { cache.save(&TokenKind::StatusList, "jwt", token, now); - assert!(cache.find(&TokenKind::StatusList, "jwt").is_none()); + assert!( + cache.find(&TokenKind::StatusList, "jwt").is_none(), + "max_ttl=0 should not cache tokens without exp" + ); } #[test] @@ -314,6 +315,10 @@ mod tests { let cache = token_cache(5); let token = token_with_exp(now, 3600); - assert_eq!(cache.cache_duration(&token, now), Some(5)); + assert_eq!( + cache.cache_duration(&token, now), + Some(5), + "positive max_ttl should cap the cache duration for tokens with exp" + ); } } From c86a109fbb34641e334f973dde0c5e2900aa30d0 Mon Sep 17 00:00:00 2001 From: Kiran Kumar Pradhan Date: Fri, 29 May 2026 18:12:26 +0530 Subject: [PATCH 3/4] docs(cedarling): document zero token cache TTL behavior Document that CEDARLING_TOKEN_CACHE_MAX_TTL=0 disables the token cache entirely. Signed-off-by: Kiran Kumar Pradhan --- docs/cedarling/reference/cedarling-properties.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cedarling/reference/cedarling-properties.md b/docs/cedarling/reference/cedarling-properties.md index 1d367d64554..e74e145ce66 100644 --- a/docs/cedarling/reference/cedarling-properties.md +++ b/docs/cedarling/reference/cedarling-properties.md @@ -121,7 +121,7 @@ Also called Token-based Access Control (TBAC). This is the recommended authoriza **Token cache:** -- **`CEDARLING_TOKEN_CACHE_MAX_TTL`** : Maximum token cache TTL in seconds. The token cache avoids decoding and validating the same token twice. Default is `5` seconds — small enough that revocation and status-list changes are picked up quickly, large enough to amortise repeated requests for the same token. Effective TTL is `min(time-until-exp, max_ttl)` when both apply. Setting this to `0` disables the cap — the token's `exp` claim is used as the entry TTL; tokens without `exp` are then not cached at all. +- **`CEDARLING_TOKEN_CACHE_MAX_TTL`** : Maximum token cache TTL in seconds. The token cache avoids decoding and validating the same token twice. Default is `5` seconds — small enough that revocation and status-list changes are picked up quickly, large enough to amortise repeated requests for the same token. Effective TTL is `min(time-until-exp, max_ttl)` when both apply. Setting this to `0` disables the token cache entirely. - **`CEDARLING_TOKEN_CACHE_CAPACITY`** : Maximum number of tokens the cache can store. Default value is 100. 0 means no limit. - **`CEDARLING_TOKEN_CACHE_EARLIEST_EXPIRATION_EVICTION`** : Enables eviction policy based on the earliest expiration time. When the cache reaches its capacity, the entry with the nearest expiration timestamp will be removed to make room for a new one. Default value is `true`. From 3f70a0d4d4d30fda05b38f04f6a00149851ee9af Mon Sep 17 00:00:00 2001 From: Kiran Kumar Pradhan Date: Mon, 1 Jun 2026 15:46:50 +0530 Subject: [PATCH 4/4] fix(jans-cedarling): model disabled token cache explicitly Represent CEDARLING_TOKEN_CACHE_MAX_TTL=0 as an absent backing cache so disabled cache behavior is handled at construction and cache operations naturally no-op. Signed-off-by: Kiran Kumar Pradhan --- .../cedarling/src/jwt/token_cache.rs | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/jans-cedarling/cedarling/src/jwt/token_cache.rs b/jans-cedarling/cedarling/src/jwt/token_cache.rs index 50bfd39b895..dba9181b02a 100644 --- a/jans-cedarling/cedarling/src/jwt/token_cache.rs +++ b/jans-cedarling/cedarling/src/jwt/token_cache.rs @@ -21,7 +21,7 @@ use crate::LogLevel; /// based on token expiration claims and a configurable maximum TTL. #[derive(Clone)] pub(crate) struct TokenCache { - cache: Arc>>>, + cache: Option>>>>, max_ttl: usize, logger: Option, metrics: Arc, @@ -57,13 +57,17 @@ impl TokenCache { logger: Option, metrics: Arc, ) -> Self { - Self { - cache: Arc::new(RwLock::new(SparKV::with_config(Config { + let cache = (max_ttl > 0).then(|| { + Arc::new(RwLock::new(SparKV::with_config(Config { max_ttl: Duration::seconds(i64::try_from(max_ttl).unwrap_or_default()), max_items: capacity, earliest_expiration_eviction, ..Default::default() - }))), + }))) + }); + + Self { + cache, max_ttl, logger, metrics, @@ -87,14 +91,13 @@ impl TokenCache { /// Returns `Some(Arc)` if the token is found in cache and not expired, /// otherwise returns `None`. pub(crate) fn find(&self, kind: &TokenKind, jwt: &str) -> Option> { - if self.max_ttl == 0 { + let Some(cache) = &self.cache else { self.metrics.record_cache_miss(); return None; - } + }; let key = hash_jwt_token(kind, jwt); - let result = self - .cache + let result = cache .read() .expect("token cache mutex shouldn't be poisoned") .get(&key) @@ -115,9 +118,9 @@ impl TokenCache { /// 2. If the token has no `exp` claim and `max_ttl` is > 0, `max_ttl` is used. /// 3. If `max_ttl` is 0, the token cache is disabled and no token is cached. pub(crate) fn save(&self, kind: &TokenKind, jwt: &str, token: Arc, now: DateTime) { - if self.max_ttl == 0 { + let Some(cache) = &self.cache else { return; - } + }; if TokenCache::check_token_expired(&token, now) { // token is expired, no need to save it @@ -137,8 +140,7 @@ impl TokenCache { .map(|iss| vec![IndexKey::Iss(iss).index_value()]) .unwrap_or_default(); - let result = self - .cache + let result = cache .write() .expect("token cache mutex shouldn't be poisoned") .set_with_ttl(&key, token, Duration::seconds(duration), &index_keys); @@ -197,8 +199,11 @@ impl TokenCache { /// This should be called periodically to prevent memory leaks from /// accumulated expired tokens. pub(crate) fn clear_expired(&self) { - let cleared = self - .cache + let Some(cache) = &self.cache else { + return; + }; + + let cleared = cache .write() .expect("token cache mutex shouldn't be poisoned") .clear_expired(); @@ -209,7 +214,11 @@ impl TokenCache { /// Remove tokens from cache by index key pub(crate) fn invalidate_by_index(&self, index_key: &IndexKey) { - self.cache + let Some(cache) = &self.cache else { + return; + }; + + cache .write() .expect("token cache mutex shouldn't be poisoned") .remove_by_index(&index_key.index_value());