Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cedarling/reference/cedarling-properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub struct JwtConfig {
/// Only tokens signed with algorithms in this list can be valid.
pub signature_algorithms_supported: HashSet<Algorithm>,
/// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 96 additions & 17 deletions jans-cedarling/cedarling/src/jwt/token_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ 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.
///
/// The cache uses a thread-safe key-value store with automatic expiration
/// based on token expiration claims and a configurable maximum TTL.
#[derive(Clone)]
pub(crate) struct TokenCache {
cache: Arc<RwLock<SparKV<Arc<Token>>>>,
cache: Option<Arc<RwLock<SparKV<Arc<Token>>>>>,
max_ttl: usize,
logger: Option<Logger>,
metrics: Arc<MetricsCollector>,
Expand Down Expand Up @@ -49,22 +49,25 @@ 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,
earliest_expiration_eviction: bool,
logger: Option<Logger>,
metrics: Arc<MetricsCollector>,
) -> 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,
Expand All @@ -88,9 +91,13 @@ impl TokenCache {
/// Returns `Some(Arc<Token>)` if the token is found in cache and not expired,
/// otherwise returns `None`.
pub(crate) fn find(&self, kind: &TokenKind, jwt: &str) -> Option<Arc<Token>> {
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)
Expand All @@ -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<Token>, now: DateTime<Utc>) {
let Some(cache) = &self.cache else {
return;
};

if TokenCache::check_token_expired(&token, now) {
// token is expired, no need to save it
return;
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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());
Expand Down Expand Up @@ -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<String, Value>) -> Arc<Token> {
Arc::new(Token::new("test", TokenClaims::from(claims), None))
}

fn token_with_exp(now: DateTime<Utc>, duration_secs: i64) -> Arc<Token> {
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"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}