use std::hash::Hash;
use std::sync::{Arc, RwLock};
use chrono::{DateTime, Duration, Utc};
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::sparkv::{Config, HashMapSparKV};
#[derive(Clone)]
pub(crate) struct TokenCache {
cache: Option<Arc<RwLock<HashMapSparKV<Arc<Token>>>>>,
max_ttl: usize,
logger: Option<Logger>,
metrics: Arc<MetricsCollector>,
}
#[cfg(test)]
impl Default for TokenCache {
fn default() -> Self {
use crate::log::TEST_LOGGER;
Self::new(
60 * 5,
100,
true,
Some(TEST_LOGGER.clone()),
Arc::new(MetricsCollector::new(0)),
)
}
}
impl TokenCache {
pub(crate) fn new(
max_ttl: usize,
capacity: usize,
earliest_expiration_eviction: bool,
logger: Option<Logger>,
metrics: Arc<MetricsCollector>,
) -> Self {
let cache = (max_ttl > 0).then(|| {
Arc::new(RwLock::new(HashMapSparKV::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,
}
}
fn log_warn(&self, msg: String) {
if let Some(logger) = &self.logger {
logger.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::WARN,
None,
))
.set_message(msg),
);
}
}
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 = cache
.read()
.expect("token cache mutex shouldn't be poisoned")
.get(&key)
.map(std::borrow::ToOwned::to_owned);
if result.is_some() {
self.metrics.record_cache_hit();
} else {
self.metrics.record_cache_miss();
}
result
}
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) {
return;
}
let Some(duration) = self.cache_duration(&token, now) else {
return;
};
let key = hash_jwt_token(kind, jwt);
let index_keys = token
.extract_normalized_issuer()
.map(|iss| vec![IndexKey::Iss(iss).index_value()])
.unwrap_or_default();
let result = cache
.write()
.expect("token cache mutex shouldn't be poisoned")
.set_with_ttl(&key, token, Duration::seconds(duration), &index_keys);
if let Err(err) = result {
self.log_warn(format!("could not set token to token cache: {err}"));
}
}
fn check_token_expired(token: &Arc<Token>, now: DateTime<Utc>) -> bool {
token
.claims
.get_claim("exp")
.and_then(|exp| exp.value().as_i64())
.is_some_and(|exp| exp <= now.timestamp())
}
fn cache_duration(&self, token: &Arc<Token>, now: DateTime<Utc>) -> Option<i64> {
token
.claims
.get_claim("exp")
.and_then(|exp| exp.value().as_i64())
.and_then(|exp| {
let duration = exp - now.timestamp();
if duration > 0 {
if let Ok(max_ttl_i64) = i64::try_from(self.max_ttl) {
Some(if self.max_ttl > 0 && duration > max_ttl_i64 {
max_ttl_i64
} else {
duration
})
} else {
Some(duration)
}
} else {
None
}
})
.or({
if self.max_ttl > 0 {
i64::try_from(self.max_ttl).ok()
} else {
None
}
})
}
pub(crate) fn clear_expired(&self) {
let Some(cache) = &self.cache else {
return;
};
let cleared = cache
.write()
.expect("token cache mutex shouldn't be poisoned")
.clear_expired();
if cleared > 0 {
self.metrics.record_cache_eviction(cleared);
}
}
pub(crate) fn invalidate_by_index(&self, index_key: &IndexKey) {
let Some(cache) = &self.cache else {
return;
};
cache
.write()
.expect("token cache mutex shouldn't be poisoned")
.remove_by_index(&index_key.index_value());
}
}
fn hash_jwt_token(kind: &TokenKind, jwt: &str) -> String {
use core::hash::BuildHasher;
use std::hash::Hasher;
use std::sync::LazyLock;
static HASHER_KEYS: LazyLock<(u64, u64, u64, u64)> = LazyLock::new(|| {
(
rand::random(),
rand::random(),
rand::random(),
rand::random(),
)
});
let mut hasher =
ahash::RandomState::with_seeds(HASHER_KEYS.0, HASHER_KEYS.1, HASHER_KEYS.2, HASHER_KEYS.3)
.build_hasher();
kind.hash(&mut hasher);
jwt.hash(&mut hasher);
hasher.finish().to_string()
}
#[derive(Debug, derive_more::Display)]
pub(crate) enum IndexKey {
#[display("iss:{_0}")]
Iss(IssClaim),
}
impl IndexKey {
fn index_value(&self) -> String {
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<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"
);
}
}