use lru::LruCache;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
const KEY_LEN: usize = 32;
const FALLBACK_TTL: Duration = Duration::from_secs(60);
const MAX_TTL: Duration = Duration::from_secs(300);
#[derive(Clone, Debug)]
struct CachedEntry {
sub: String,
scopes: HashSet<String>,
expires_at: SystemTime,
}
pub(crate) struct TokenCache {
inner: Mutex<LruCache<[u8; KEY_LEN], CachedEntry>>,
}
impl std::fmt::Debug for TokenCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenCache").finish_non_exhaustive()
}
}
impl TokenCache {
pub fn new(capacity: NonZeroUsize) -> Self {
Self {
inner: Mutex::new(LruCache::new(capacity)),
}
}
pub fn get(&self, token: &str) -> Option<(String, HashSet<String>)> {
let key = hash_token(token);
let mut guard = self.inner.lock().unwrap_or_else(|poisoned| {
poisoned.into_inner()
});
let entry = guard.get(&key)?;
if entry.expires_at <= SystemTime::now() {
guard.pop(&key);
return None;
}
Some((entry.sub.clone(), entry.scopes.clone()))
}
pub fn insert(&self, token: &str, sub: String, scopes: HashSet<String>, exp_unix: Option<u64>) {
let expires_at = bounded_expiry(exp_unix);
let key = hash_token(token);
let mut guard = self
.inner
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
guard.put(
key,
CachedEntry {
sub,
scopes,
expires_at,
},
);
}
}
fn bounded_expiry(exp_unix: Option<u64>) -> SystemTime {
let now = SystemTime::now();
let ceiling = now + MAX_TTL;
let Some(exp) = exp_unix else {
return now + FALLBACK_TTL;
};
let claimed = SystemTime::UNIX_EPOCH
.checked_add(Duration::from_secs(exp))
.unwrap_or(ceiling);
claimed.min(ceiling)
}
fn hash_token(token: &str) -> [u8; KEY_LEN] {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
let mut out = [0u8; KEY_LEN];
out.copy_from_slice(&digest);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cap(n: usize) -> NonZeroUsize {
NonZeroUsize::new(n).expect("test capacity > 0")
}
#[test]
fn miss_returns_none() {
let cache = TokenCache::new(cap(4));
assert!(cache.get("never-inserted").is_none());
}
#[test]
fn insert_then_get_returns_sub_and_scopes() {
let cache = TokenCache::new(cap(4));
let scopes: HashSet<String> = ["klieo:read".into()].into();
let future = now_unix() + 120;
cache.insert("tok", "alice".into(), scopes.clone(), Some(future));
let (sub, got_scopes) = cache.get("tok").expect("hit");
assert_eq!(sub, "alice");
assert_eq!(got_scopes, scopes);
}
#[test]
fn expired_entry_evicted_on_get() {
let cache = TokenCache::new(cap(4));
let past = now_unix() - 10;
cache.insert("tok", "alice".into(), HashSet::new(), Some(past));
assert!(cache.get("tok").is_none());
}
#[test]
fn missing_exp_falls_back_to_short_ttl() {
let expiry = bounded_expiry(None);
let now = SystemTime::now();
let delta = expiry.duration_since(now).unwrap_or(Duration::ZERO);
assert!(delta <= FALLBACK_TTL);
}
#[test]
fn pathological_exp_clamped_to_max_ttl() {
let absurd = now_unix() + 60 * 60 * 24 * 365;
let expiry = bounded_expiry(Some(absurd));
let now = SystemTime::now();
let delta = expiry.duration_since(now).unwrap_or(Duration::ZERO);
assert!(delta <= MAX_TTL + Duration::from_secs(1));
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs()
}
}