1use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Mutex;
16use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20use crate::{extract_bearer_token, validate_token, AuthResult, JwtConfig, JwtError};
21
22const CAPACITY: usize = 10_000;
27
28struct Entry {
29 result: AuthResult,
30 expires: Instant,
31}
32
33pub struct JwtCache {
35 entries: Mutex<HashMap<String, Entry>>,
36 max_lifetime: Duration,
37 keys_generation: AtomicU64,
46 hits: AtomicU64,
52 misses: AtomicU64,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum CacheOutcome {
59 Hit,
61 Miss,
64 Bypassed,
69}
70
71impl CacheOutcome {
72 pub fn as_str(self) -> &'static str {
74 match self {
75 CacheOutcome::Hit => "hit",
76 CacheOutcome::Miss => "miss",
77 CacheOutcome::Bypassed => "bypassed",
78 }
79 }
80}
81
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct CacheStats {
86 pub hits: u64,
88 pub misses: u64,
90}
91
92impl JwtCache {
93 pub fn new(enabled: bool, max_lifetime_secs: u64) -> Option<Self> {
98 if !enabled || max_lifetime_secs == 0 {
99 return None;
100 }
101 Some(Self {
102 entries: Mutex::new(HashMap::new()),
103 max_lifetime: Duration::from_secs(max_lifetime_secs),
104 keys_generation: AtomicU64::new(0),
105 hits: AtomicU64::new(0),
106 misses: AtomicU64::new(0),
107 })
108 }
109
110 pub fn clear(&self) {
115 self.entries
116 .lock()
117 .unwrap_or_else(|e| e.into_inner())
118 .clear();
119 }
120
121 pub fn stats(&self) -> CacheStats {
123 CacheStats {
124 hits: self.hits.load(Ordering::Relaxed),
125 misses: self.misses.load(Ordering::Relaxed),
126 }
127 }
128
129 pub fn authenticate(
135 &self,
136 auth_header: Option<&str>,
137 config: &JwtConfig,
138 ) -> Result<AuthResult, JwtError> {
139 self.authenticate_with_outcome(auth_header, config).0
140 }
141
142 pub fn authenticate_with_outcome(
149 &self,
150 auth_header: Option<&str>,
151 config: &JwtConfig,
152 ) -> (Result<AuthResult, JwtError>, CacheOutcome) {
153 let Some(header) = auth_header else {
154 return (crate::authenticate(None, config), CacheOutcome::Bypassed);
155 };
156 let token = match extract_bearer_token(header) {
157 Ok(token) => token,
158 Err(e) => return (Err(e), CacheOutcome::Bypassed),
159 };
160
161 let generation = config.keys.as_ref().map_or(0, crate::JwtKeys::generation);
165
166 if let Some(hit) = self.get_under(token, generation) {
167 self.hits.fetch_add(1, Ordering::Relaxed);
168 return (Ok(hit), CacheOutcome::Hit);
169 }
170 self.misses.fetch_add(1, Ordering::Relaxed);
171 (
172 self.verify_and_store(token, config, generation),
173 CacheOutcome::Miss,
174 )
175 }
176
177 fn verify_and_store(
180 &self,
181 token: &str,
182 config: &JwtConfig,
183 generation: u64,
184 ) -> Result<AuthResult, JwtError> {
185 let result = validate_token(token, config)?;
186
187 let ttl = match remaining_lifetime(&result) {
191 Expiry::Absent => self.max_lifetime,
194 Expiry::In(remaining) if remaining.is_zero() => return Ok(result),
195 Expiry::In(remaining) => self.max_lifetime.min(remaining),
196 Expiry::Unreadable => return Ok(result),
201 };
202
203 self.insert_under(token.to_string(), result.clone(), ttl, generation);
204 Ok(result)
205 }
206
207 fn get_under(&self, token: &str, generation: u64) -> Option<AuthResult> {
217 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
218 if generation > self.keys_generation.load(Ordering::Relaxed) {
219 entries.clear();
220 self.keys_generation.store(generation, Ordering::Relaxed);
221 }
222 Self::lookup(&mut entries, token)
223 }
224
225 fn insert_under(&self, token: String, result: AuthResult, ttl: Duration, generation: u64) {
233 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
234 if self.keys_generation.load(Ordering::Relaxed) != generation {
235 return;
236 }
237 Self::store(&mut entries, token, result, ttl);
238 }
239
240 #[cfg(test)]
241 fn get(&self, token: &str) -> Option<AuthResult> {
242 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
243 Self::lookup(&mut entries, token)
244 }
245
246 fn lookup(entries: &mut HashMap<String, Entry>, token: &str) -> Option<AuthResult> {
247 let entry = entries.get(token)?;
248 if entry.expires > Instant::now() {
249 return Some(entry.result.clone());
250 }
251 entries.remove(token);
252 None
253 }
254
255 #[cfg(test)]
256 fn insert(&self, token: String, result: AuthResult, ttl: Duration) {
257 let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
258 Self::store(&mut entries, token, result, ttl);
259 }
260
261 fn store(
262 entries: &mut HashMap<String, Entry>,
263 token: String,
264 result: AuthResult,
265 ttl: Duration,
266 ) {
267 let now = Instant::now();
268
269 if entries.len() >= CAPACITY {
270 entries.retain(|_, e| e.expires > now);
271 if entries.len() >= CAPACITY {
274 entries.clear();
275 }
276 }
277
278 entries.insert(
279 token,
280 Entry {
281 result,
282 expires: now + ttl,
283 },
284 );
285 }
286}
287
288enum Expiry {
290 Absent,
292 In(Duration),
294 Unreadable,
297}
298
299fn remaining_lifetime(result: &AuthResult) -> Expiry {
303 let Some(exp) = result.claims.get("exp") else {
304 return Expiry::Absent;
305 };
306
307 let exp_secs = match exp.as_i64() {
308 Some(n) => n,
309 None => match exp.as_f64() {
312 Some(f) if f.is_finite() => f.trunc() as i64,
313 _ => return Expiry::Unreadable,
314 },
315 };
316
317 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
318 return Expiry::Unreadable;
319 };
320
321 Expiry::In(Duration::from_secs(
322 exp_secs.saturating_sub(now.as_secs() as i64).max(0) as u64,
323 ))
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use serde_json::json;
330
331 fn result_with(claims: serde_json::Value) -> AuthResult {
332 AuthResult {
333 role: "web_user".to_string(),
334 claims: claims
335 .as_object()
336 .unwrap()
337 .iter()
338 .map(|(k, v)| (k.clone(), v.clone()))
339 .collect(),
340 }
341 }
342
343 #[test]
344 fn disabled_by_flag_or_by_zero_lifetime() {
345 assert!(JwtCache::new(false, 3600).is_none());
346 assert!(JwtCache::new(true, 0).is_none());
347 assert!(JwtCache::new(true, 1).is_some());
348 }
349
350 #[test]
351 fn a_hit_is_returned_and_an_expired_entry_is_not() {
352 let cache = JwtCache::new(true, 3600).unwrap();
353 let result = result_with(json!({"sub": "alice"}));
354
355 cache.insert("tok".to_string(), result.clone(), Duration::from_secs(60));
356 assert_eq!(cache.get("tok").unwrap().role, "web_user");
357
358 cache.insert("stale".to_string(), result, Duration::from_secs(0));
360 assert!(cache.get("stale").is_none());
361 assert!(!cache.entries.lock().unwrap().contains_key("stale"));
363 }
364
365 fn now_secs() -> i64 {
366 SystemTime::now()
367 .duration_since(UNIX_EPOCH)
368 .unwrap()
369 .as_secs() as i64
370 }
371
372 #[test]
373 fn exp_bounds_the_lifetime_but_does_not_extend_it() {
374 let soon = now_secs() + 30;
375 let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
376 panic!("an integer exp should be readable");
377 };
378 assert!(remaining <= Duration::from_secs(30));
379 assert!(remaining > Duration::from_secs(25));
380
381 let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": soon - 600})))
383 else {
384 panic!("an integer exp should be readable");
385 };
386 assert!(none_left.is_zero());
387
388 assert!(matches!(
390 remaining_lifetime(&result_with(json!({"sub": "alice"}))),
391 Expiry::Absent
392 ));
393 }
394
395 #[test]
396 fn a_fractional_exp_is_read_rather_than_treated_as_absent() {
397 let soon = now_secs() as f64 + 30.5;
402 let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
403 panic!("a fractional exp must not read as absent or unreadable");
404 };
405 assert!(remaining <= Duration::from_secs(31));
406 assert!(remaining > Duration::from_secs(25));
407
408 let past = now_secs() as f64 - 600.5;
410 let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": past}))) else {
411 panic!("a fractional exp must not read as absent or unreadable");
412 };
413 assert!(none_left.is_zero());
414 }
415
416 #[test]
417 fn an_exp_that_is_not_a_number_is_unreadable_not_absent() {
418 for value in [json!({"exp": "soon"}), json!({"exp": null})] {
421 assert!(
422 matches!(
423 remaining_lifetime(&result_with(value.clone())),
424 Expiry::Unreadable
425 ),
426 "{value:?}"
427 );
428 }
429 }
430
431 #[test]
432 fn a_token_with_an_unreadable_exp_is_not_cached() {
433 let cache = JwtCache::new(true, 3600).unwrap();
434 assert!(cache.get("tok").is_none());
437 assert!(cache.entries.lock().unwrap().is_empty());
438 }
439
440 #[test]
441 fn a_lookup_says_whether_the_cache_answered() {
442 let config = JwtConfig {
443 keys: crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
444 .unwrap(),
445 ..JwtConfig::default()
446 };
447 let token = jsonwebtoken::encode(
448 &jsonwebtoken::Header::default(),
449 &json!({"role": "web_user", "exp": now_secs() + 600}),
450 &jsonwebtoken::EncodingKey::from_secret(b"reallyreallyreallyreallyverysafe"),
451 )
452 .unwrap();
453 let header = format!("Bearer {token}");
454 let cache = JwtCache::new(true, 3600).unwrap();
455
456 let (first, outcome) = cache.authenticate_with_outcome(Some(&header), &config);
457 assert_eq!(first.unwrap().role, "web_user");
458 assert_eq!(outcome, CacheOutcome::Miss);
459 let (second, outcome) = cache.authenticate_with_outcome(Some(&header), &config);
460 assert_eq!(second.unwrap().role, "web_user");
461 assert_eq!(outcome, CacheOutcome::Hit);
462
463 let (_, outcome) = cache.authenticate_with_outcome(None, &config);
465 assert_eq!(outcome, CacheOutcome::Bypassed);
466
467 let (refused, outcome) = cache.authenticate_with_outcome(Some("Bearer nope"), &config);
469 assert!(refused.is_err());
470 assert_eq!(outcome, CacheOutcome::Miss);
471
472 assert_eq!(cache.stats(), CacheStats { hits: 1, misses: 2 });
473 assert!(cache.authenticate(Some(&header), &config).is_ok());
475 assert_eq!(cache.stats().hits, 2);
476 }
477
478 #[test]
483 fn a_request_from_before_a_rotation_cannot_bring_the_old_keys_back() {
484 let cache = JwtCache::new(true, 3600).unwrap();
485 let (old, new) = (1, 2);
486 let ttl = Duration::from_secs(60);
487
488 assert!(cache.get_under("after", new).is_none());
489 cache.insert_under("after".into(), result_with(json!({})), ttl, new);
490
491 assert!(cache.get_under("before", old).is_none());
493 assert!(cache.get_under("after", old).is_some());
494 cache.insert_under("withdrawn".into(), result_with(json!({})), ttl, old);
496 assert!(cache.get_under("withdrawn", new).is_none());
497 assert!(cache.get_under("after", new).is_some());
498 }
499
500 #[test]
502 fn generations_only_increase() {
503 let a = crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
504 .unwrap()
505 .unwrap();
506 let first = a.generation();
507 assert!(first > 0);
508 let b = crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
509 .unwrap()
510 .unwrap();
511 assert!(b.generation() > first);
512 a.replace(crate::KeySet::from_secret("anotheranotheranotheranother1234", false).unwrap());
513 assert!(a.generation() > b.generation());
514 }
515
516 #[test]
517 fn the_cache_is_bounded() {
518 let cache = JwtCache::new(true, 3600).unwrap();
519 let result = result_with(json!({"sub": "alice"}));
520 for i in 0..CAPACITY + 50 {
521 cache.insert(format!("tok{i}"), result.clone(), Duration::from_secs(600));
522 }
523 assert!(cache.entries.lock().unwrap().len() <= CAPACITY);
524 }
525}