Skip to main content

kellnr_common/
token_cache.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::Duration;
3
4use moka::future::Cache;
5
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct CachedTokenData {
8    pub user: String,
9    pub is_admin: bool,
10    pub is_read_only: bool,
11}
12
13pub struct TokenCacheManager {
14    cache: Option<Cache<String, CachedTokenData>>,
15    /// Bumped by every [`TokenCacheManager::invalidate_all`]. A lookup that
16    /// started before an invalidation must not cache its (possibly stale)
17    /// result, so inserts are tied to the generation read before the lookup.
18    generation: AtomicU64,
19}
20
21impl TokenCacheManager {
22    /// Creates a new `TokenCacheManager`.
23    ///
24    /// # Arguments
25    /// * `enabled` - Whether caching is enabled
26    /// * `ttl_seconds` - Time-to-live for cached tokens. Lower values provide
27    ///   better security (revoked tokens expire faster) but increase database load.
28    /// * `max_capacity` - Maximum number of tokens to cache
29    pub fn new(enabled: bool, ttl_seconds: u64, max_capacity: u64) -> Self {
30        let cache = if enabled {
31            Some(
32                Cache::builder()
33                    .max_capacity(max_capacity)
34                    .time_to_live(Duration::from_secs(ttl_seconds))
35                    .build(),
36            )
37        } else {
38            None
39        };
40
41        Self {
42            cache,
43            generation: AtomicU64::new(0),
44        }
45    }
46
47    pub async fn get(&self, token: &str) -> Option<CachedTokenData> {
48        match &self.cache {
49            Some(cache) => cache.get(token).await,
50            None => None,
51        }
52    }
53
54    /// Current invalidation generation. Read it before the database lookup
55    /// whose result is passed to [`TokenCacheManager::insert`].
56    pub fn generation(&self) -> u64 {
57        self.generation.load(Ordering::SeqCst)
58    }
59
60    /// Caches `data` unless the cache was invalidated since `generation` was
61    /// read. The generation is checked again after the insert: an
62    /// invalidation racing with the insert removes the fresh entry, so a
63    /// revoked token or outdated privileges never outlive an invalidation.
64    pub async fn insert(&self, token: String, data: CachedTokenData, generation: u64) {
65        let Some(cache) = &self.cache else {
66            return;
67        };
68        if self.generation() != generation {
69            return;
70        }
71        cache.insert(token.clone(), data).await;
72        if self.generation() != generation {
73            cache.invalidate(&token).await;
74        }
75    }
76
77    pub fn invalidate_all(&self) {
78        // Bump first so in-flight lookups observe the invalidation before the
79        // entries are dropped.
80        self.generation.fetch_add(1, Ordering::SeqCst);
81        if let Some(cache) = &self.cache {
82            cache.invalidate_all();
83        }
84    }
85
86    /// Invalidate a single token from the cache.
87    pub async fn invalidate(&self, token: &str) {
88        if let Some(cache) = &self.cache {
89            cache.invalidate(token).await;
90        }
91    }
92
93    pub fn is_enabled(&self) -> bool {
94        self.cache.is_some()
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn data(is_admin: bool, is_read_only: bool) -> CachedTokenData {
103        CachedTokenData {
104            user: "test_user".to_string(),
105            is_admin,
106            is_read_only,
107        }
108    }
109
110    #[tokio::test]
111    async fn test_cache_disabled() {
112        let cache = TokenCacheManager::new(false, 60, 100);
113        assert!(!cache.is_enabled());
114
115        cache
116            .insert(
117                "token123".to_string(),
118                data(false, false),
119                cache.generation(),
120            )
121            .await;
122        assert!(cache.get("token123").await.is_none());
123    }
124
125    #[tokio::test]
126    async fn test_cache_enabled() {
127        let cache = TokenCacheManager::new(true, 60, 100);
128        assert!(cache.is_enabled());
129
130        cache
131            .insert(
132                "token123".to_string(),
133                data(true, false),
134                cache.generation(),
135            )
136            .await;
137
138        let retrieved = cache.get("token123").await;
139        assert!(retrieved.is_some());
140        let retrieved = retrieved.unwrap();
141        assert_eq!(retrieved.user, "test_user");
142        assert!(retrieved.is_admin);
143        assert!(!retrieved.is_read_only);
144    }
145
146    #[tokio::test]
147    async fn test_cache_miss() {
148        let cache = TokenCacheManager::new(true, 60, 100);
149        assert!(cache.get("nonexistent").await.is_none());
150    }
151
152    #[tokio::test]
153    async fn test_invalidate_all() {
154        let cache = TokenCacheManager::new(true, 60, 100);
155
156        let generation = cache.generation();
157        cache
158            .insert("token1".to_string(), data(false, true), generation)
159            .await;
160        cache
161            .insert("token2".to_string(), data(false, true), generation)
162            .await;
163
164        assert!(cache.get("token1").await.is_some());
165        assert!(cache.get("token2").await.is_some());
166
167        cache.invalidate_all();
168
169        assert!(cache.get("token1").await.is_none());
170        assert!(cache.get("token2").await.is_none());
171    }
172
173    /// A lookup that read the generation before a revocation must not cache
174    /// its result afterwards.
175    #[tokio::test]
176    async fn insert_after_invalidation_is_dropped() {
177        let cache = TokenCacheManager::new(true, 60, 100);
178
179        let generation = cache.generation();
180        cache.invalidate_all();
181        cache
182            .insert("token".to_string(), data(true, false), generation)
183            .await;
184
185        assert!(cache.get("token").await.is_none());
186    }
187
188    #[tokio::test]
189    async fn invalidate_all_bumps_generation() {
190        let cache = TokenCacheManager::new(false, 60, 100);
191        let before = cache.generation();
192
193        cache.invalidate_all();
194
195        assert_ne!(before, cache.generation());
196    }
197}