use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::Result; use mongodb::bson::oid::ObjectId; use tokio::sync::RwLock; use crate::db::MongoDb; /// How long a cached token_version is considered fresh before re-querying Mongo. const DEFAULT_TTL: Duration = Duration::from_secs(30); /// Short-TTL in-memory cache of `{ user_id -> token_version }`. /// /// This lets the JWT auth middleware reject access tokens whose `token_version` /// claim is stale (e.g. issued before a password change) without doing a Mongo /// lookup on every request. Stale entries are evicted on credential changes via /// [`TokenVersionCache::invalidate`], so a version bump becomes visible within /// at most `ttl` on other instances, and immediately on the instance that /// processed the change. #[derive(Clone)] pub struct TokenVersionCache { map: Arc>>, ttl: Duration, } impl TokenVersionCache { pub fn new(ttl: Duration) -> Self { Self { map: Arc::new(RwLock::new(HashMap::new())), ttl, } } /// Build a cache with the default 30s TTL. pub fn with_default_ttl() -> Self { Self::new(DEFAULT_TTL) } /// Return the user's current `token_version`, loading it from Mongo on a /// cache miss or once the cached value is older than the TTL. /// /// Returns `Ok(None)` when the user no longer exists (deleted), so callers /// can reject the token as unauthorized. pub async fn get_or_load(&self, user_id: &ObjectId, db: &MongoDb) -> Result> { // Fast path: serve from cache if fresh. { let cache = self.map.read().await; if let Some((version, fetched_at)) = cache.get(user_id) { if fetched_at.elapsed() < self.ttl { return Ok(Some(*version)); } } } // Slow path: query Mongo and refresh the cache. let version = match db.find_user_by_id(user_id).await? { Some(user) => Some(user.token_version), None => None, }; let mut cache = self.map.write().await; match version { Some(v) => { cache.insert(*user_id, (v, Instant::now())); } None => { // User was deleted: drop any stale entry so we don't serve it. cache.remove(user_id); } } Ok(version) } /// Drop the cached entry for a user. Call this whenever a user's /// `token_version` is bumped (password change / recovery) so that the new /// version becomes visible immediately instead of waiting for TTL expiry. pub async fn invalidate(&self, user_id: &ObjectId) { let mut cache = self.map.write().await; cache.remove(user_id); } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn invalidate_removes_entry() { // We can't easily exercise get_or_load without Mongo, but invalidate on // an empty cache is a no-op and must not panic. let cache = TokenVersionCache::with_default_ttl(); cache.invalidate(&ObjectId::new()).await; assert!(cache.map.read().await.is_empty()); } }