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`. 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 2811c865249..dba9181b02a 100644 --- a/jans-cedarling/cedarling/src/jwt/token_cache.rs +++ b/jans-cedarling/cedarling/src/jwt/token_cache.rs @@ -8,12 +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}; +use crate::LogLevel; /// A dedicated cache for storing validated JWT tokens. /// @@ -21,7 +21,7 @@ use crate::log::{BaseLogEntry, LogEntry, LogWriter, Logger}; /// 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, @@ -49,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, @@ -58,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, @@ -88,9 +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> { + 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) @@ -109,10 +116,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) { + let Some(cache) = &self.cache else { + return; + }; + if TokenCache::check_token_expired(&token, now) { // token is expired, no need to save it return; @@ -131,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); @@ -191,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(); @@ -203,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()); @@ -252,3 +267,67 @@ impl IndexKey { self.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::jwt::token::TokenClaims; + use serde_json::{json, Value}; + 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_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_none(), + "max_ttl=0 should disable token cache even when the token has exp" + ); + } + + #[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(), + "max_ttl=0 should not cache tokens without exp" + ); + } + + #[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), + "positive max_ttl should cap the cache duration for tokens with exp" + ); + } +}