use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::{extract_bearer_token, validate_token, AuthResult, JwtConfig, JwtError};
const CAPACITY: usize = 10_000;
struct Entry {
result: AuthResult,
expires: Instant,
}
pub struct JwtCache {
entries: Mutex<HashMap<String, Entry>>,
max_lifetime: Duration,
keys_generation: AtomicU64,
hits: AtomicU64,
misses: AtomicU64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum CacheOutcome {
Hit,
Miss,
Bypassed,
}
impl CacheOutcome {
pub fn as_str(self) -> &'static str {
match self {
CacheOutcome::Hit => "hit",
CacheOutcome::Miss => "miss",
CacheOutcome::Bypassed => "bypassed",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct CacheStats {
pub hits: u64,
pub misses: u64,
}
impl JwtCache {
pub fn new(enabled: bool, max_lifetime_secs: u64) -> Option<Self> {
if !enabled || max_lifetime_secs == 0 {
return None;
}
Some(Self {
entries: Mutex::new(HashMap::new()),
max_lifetime: Duration::from_secs(max_lifetime_secs),
keys_generation: AtomicU64::new(0),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
})
}
pub fn clear(&self) {
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
pub fn stats(&self) -> CacheStats {
CacheStats {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
}
}
pub fn authenticate(
&self,
auth_header: Option<&str>,
config: &JwtConfig,
) -> Result<AuthResult, JwtError> {
self.authenticate_with_outcome(auth_header, config).0
}
pub fn authenticate_with_outcome(
&self,
auth_header: Option<&str>,
config: &JwtConfig,
) -> (Result<AuthResult, JwtError>, CacheOutcome) {
let Some(header) = auth_header else {
return (crate::authenticate(None, config), CacheOutcome::Bypassed);
};
let token = match extract_bearer_token(header) {
Ok(token) => token,
Err(e) => return (Err(e), CacheOutcome::Bypassed),
};
let generation = config.keys.as_ref().map_or(0, crate::JwtKeys::generation);
if let Some(hit) = self.get_under(token, generation) {
self.hits.fetch_add(1, Ordering::Relaxed);
return (Ok(hit), CacheOutcome::Hit);
}
self.misses.fetch_add(1, Ordering::Relaxed);
(
self.verify_and_store(token, config, generation),
CacheOutcome::Miss,
)
}
fn verify_and_store(
&self,
token: &str,
config: &JwtConfig,
generation: u64,
) -> Result<AuthResult, JwtError> {
let result = validate_token(token, config)?;
let ttl = match remaining_lifetime(&result) {
Expiry::Absent => self.max_lifetime,
Expiry::In(remaining) if remaining.is_zero() => return Ok(result),
Expiry::In(remaining) => self.max_lifetime.min(remaining),
Expiry::Unreadable => return Ok(result),
};
self.insert_under(token.to_string(), result.clone(), ttl, generation);
Ok(result)
}
fn get_under(&self, token: &str, generation: u64) -> Option<AuthResult> {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if generation > self.keys_generation.load(Ordering::Relaxed) {
entries.clear();
self.keys_generation.store(generation, Ordering::Relaxed);
}
Self::lookup(&mut entries, token)
}
fn insert_under(&self, token: String, result: AuthResult, ttl: Duration, generation: u64) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if self.keys_generation.load(Ordering::Relaxed) != generation {
return;
}
Self::store(&mut entries, token, result, ttl);
}
#[cfg(test)]
fn get(&self, token: &str) -> Option<AuthResult> {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
Self::lookup(&mut entries, token)
}
fn lookup(entries: &mut HashMap<String, Entry>, token: &str) -> Option<AuthResult> {
let entry = entries.get(token)?;
if entry.expires > Instant::now() {
return Some(entry.result.clone());
}
entries.remove(token);
None
}
#[cfg(test)]
fn insert(&self, token: String, result: AuthResult, ttl: Duration) {
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
Self::store(&mut entries, token, result, ttl);
}
fn store(
entries: &mut HashMap<String, Entry>,
token: String,
result: AuthResult,
ttl: Duration,
) {
let now = Instant::now();
if entries.len() >= CAPACITY {
entries.retain(|_, e| e.expires > now);
if entries.len() >= CAPACITY {
entries.clear();
}
}
entries.insert(
token,
Entry {
result,
expires: now + ttl,
},
);
}
}
enum Expiry {
Absent,
In(Duration),
Unreadable,
}
fn remaining_lifetime(result: &AuthResult) -> Expiry {
let Some(exp) = result.claims.get("exp") else {
return Expiry::Absent;
};
let exp_secs = match exp.as_i64() {
Some(n) => n,
None => match exp.as_f64() {
Some(f) if f.is_finite() => f.trunc() as i64,
_ => return Expiry::Unreadable,
},
};
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return Expiry::Unreadable;
};
Expiry::In(Duration::from_secs(
exp_secs.saturating_sub(now.as_secs() as i64).max(0) as u64,
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn result_with(claims: serde_json::Value) -> AuthResult {
AuthResult {
role: "web_user".to_string(),
claims: claims
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}
}
#[test]
fn disabled_by_flag_or_by_zero_lifetime() {
assert!(JwtCache::new(false, 3600).is_none());
assert!(JwtCache::new(true, 0).is_none());
assert!(JwtCache::new(true, 1).is_some());
}
#[test]
fn a_hit_is_returned_and_an_expired_entry_is_not() {
let cache = JwtCache::new(true, 3600).unwrap();
let result = result_with(json!({"sub": "alice"}));
cache.insert("tok".to_string(), result.clone(), Duration::from_secs(60));
assert_eq!(cache.get("tok").unwrap().role, "web_user");
cache.insert("stale".to_string(), result, Duration::from_secs(0));
assert!(cache.get("stale").is_none());
assert!(!cache.entries.lock().unwrap().contains_key("stale"));
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
#[test]
fn exp_bounds_the_lifetime_but_does_not_extend_it() {
let soon = now_secs() + 30;
let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
panic!("an integer exp should be readable");
};
assert!(remaining <= Duration::from_secs(30));
assert!(remaining > Duration::from_secs(25));
let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": soon - 600})))
else {
panic!("an integer exp should be readable");
};
assert!(none_left.is_zero());
assert!(matches!(
remaining_lifetime(&result_with(json!({"sub": "alice"}))),
Expiry::Absent
));
}
#[test]
fn a_fractional_exp_is_read_rather_than_treated_as_absent() {
let soon = now_secs() as f64 + 30.5;
let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
panic!("a fractional exp must not read as absent or unreadable");
};
assert!(remaining <= Duration::from_secs(31));
assert!(remaining > Duration::from_secs(25));
let past = now_secs() as f64 - 600.5;
let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": past}))) else {
panic!("a fractional exp must not read as absent or unreadable");
};
assert!(none_left.is_zero());
}
#[test]
fn an_exp_that_is_not_a_number_is_unreadable_not_absent() {
for value in [json!({"exp": "soon"}), json!({"exp": null})] {
assert!(
matches!(
remaining_lifetime(&result_with(value.clone())),
Expiry::Unreadable
),
"{value:?}"
);
}
}
#[test]
fn a_token_with_an_unreadable_exp_is_not_cached() {
let cache = JwtCache::new(true, 3600).unwrap();
assert!(cache.get("tok").is_none());
assert!(cache.entries.lock().unwrap().is_empty());
}
#[test]
fn a_lookup_says_whether_the_cache_answered() {
let config = JwtConfig {
keys: crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
.unwrap(),
..JwtConfig::default()
};
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&json!({"role": "web_user", "exp": now_secs() + 600}),
&jsonwebtoken::EncodingKey::from_secret(b"reallyreallyreallyreallyverysafe"),
)
.unwrap();
let header = format!("Bearer {token}");
let cache = JwtCache::new(true, 3600).unwrap();
let (first, outcome) = cache.authenticate_with_outcome(Some(&header), &config);
assert_eq!(first.unwrap().role, "web_user");
assert_eq!(outcome, CacheOutcome::Miss);
let (second, outcome) = cache.authenticate_with_outcome(Some(&header), &config);
assert_eq!(second.unwrap().role, "web_user");
assert_eq!(outcome, CacheOutcome::Hit);
let (_, outcome) = cache.authenticate_with_outcome(None, &config);
assert_eq!(outcome, CacheOutcome::Bypassed);
let (refused, outcome) = cache.authenticate_with_outcome(Some("Bearer nope"), &config);
assert!(refused.is_err());
assert_eq!(outcome, CacheOutcome::Miss);
assert_eq!(cache.stats(), CacheStats { hits: 1, misses: 2 });
assert!(cache.authenticate(Some(&header), &config).is_ok());
assert_eq!(cache.stats().hits, 2);
}
#[test]
fn a_request_from_before_a_rotation_cannot_bring_the_old_keys_back() {
let cache = JwtCache::new(true, 3600).unwrap();
let (old, new) = (1, 2);
let ttl = Duration::from_secs(60);
assert!(cache.get_under("after", new).is_none());
cache.insert_under("after".into(), result_with(json!({})), ttl, new);
assert!(cache.get_under("before", old).is_none());
assert!(cache.get_under("after", old).is_some());
cache.insert_under("withdrawn".into(), result_with(json!({})), ttl, old);
assert!(cache.get_under("withdrawn", new).is_none());
assert!(cache.get_under("after", new).is_some());
}
#[test]
fn generations_only_increase() {
let a = crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
.unwrap()
.unwrap();
let first = a.generation();
assert!(first > 0);
let b = crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
.unwrap()
.unwrap();
assert!(b.generation() > first);
a.replace(crate::KeySet::from_secret("anotheranotheranotheranother1234", false).unwrap());
assert!(a.generation() > b.generation());
}
#[test]
fn the_cache_is_bounded() {
let cache = JwtCache::new(true, 3600).unwrap();
let result = result_with(json!({"sub": "alice"}));
for i in 0..CAPACITY + 50 {
cache.insert(format!("tok{i}"), result.clone(), Duration::from_secs(600));
}
assert!(cache.entries.lock().unwrap().len() <= CAPACITY);
}
}