klieo-auth-oauth 3.4.0

OAuth 2.0 bearer-token Authenticator for klieo HTTP transports
Documentation
//! LRU cache for verified bearer tokens.
//!
//! Keyed by SHA-256(token) so the raw bearer is never held in the
//! cache map. Entries hold `(sub, scopes, expires_at)` where
//! `expires_at` is derived from the token's `exp` claim, bounded to
//! [`MAX_TTL`] so a token claiming `exp` far in the future does not
//! get cached indefinitely. If no `exp` is available, the entry uses
//! [`FALLBACK_TTL`].
//!
//! **Revocation caveat.** A token revoked between cache insert and
//! cache expiry continues to be accepted by the authenticator. This
//! is the standard staleness/freshness trade-off for any bearer-token
//! cache; operators that require sub-minute revocation latency should
//! either configure short `exp` claims at the IdP or disable the cache
//! (capacity 0).

use lru::LruCache;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};

/// SHA-256 digest length. Used as the cache key shape.
const KEY_LEN: usize = 32;

/// Fallback expiry when the verified token carries no usable `exp`
/// claim (e.g. introspection responses without `exp`). 60s mirrors
/// the typical JWKS-refresh window.
const FALLBACK_TTL: Duration = Duration::from_secs(60);

/// Hard ceiling on cached entry lifetime. Long-lived tokens (e.g.
/// `exp` an hour out) still re-verify at least every five minutes,
/// shrinking the revocation window without disabling the cache.
const MAX_TTL: Duration = Duration::from_secs(300);

#[derive(Clone, Debug)]
struct CachedEntry {
    sub: String,
    scopes: HashSet<String>,
    expires_at: SystemTime,
}

/// Thread-safe LRU cache mapping SHA-256(token) to a previously
/// verified `(sub, scopes)` pair plus its expiry instant.
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 {
        // Never surface keys or entries — they hash to bearer tokens
        // and carry principal identifiers.
        f.debug_struct("TokenCache").finish_non_exhaustive()
    }
}

impl TokenCache {
    /// Construct an LRU with the given capacity. Capacity must be > 0;
    /// the builder only constructs a cache when the operator opts in
    /// with a positive value.
    pub fn new(capacity: NonZeroUsize) -> Self {
        Self {
            inner: Mutex::new(LruCache::new(capacity)),
        }
    }

    /// Look up `(sub, scopes)` for a token. Returns `None` on miss or
    /// when the cached entry has expired (expired entries are evicted
    /// in the same call so the LRU stays clean).
    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 mutex can only happen if a previous holder
            // panicked. Recover the guard and proceed — losing the
            // cache is acceptable; treating poisoning as fatal is not.
            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()))
    }

    /// Insert a verified `(sub, scopes)` pair keyed by the token's
    /// SHA-256 hash. `exp_unix` is the JWT/introspection `exp` claim
    /// (seconds since epoch); when present we honour it up to
    /// [`MAX_TTL`] from now, otherwise we fall back to [`FALLBACK_TTL`].
    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,
            },
        );
    }
}

/// Clamp the expiry to `[now + FALLBACK_TTL .. now + MAX_TTL]`. Missing
/// `exp` collapses to `now + FALLBACK_TTL`; pathological `exp` far in
/// the future is bounded at `now + MAX_TTL`.
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()
    }
}