Skip to main content

postrust_auth/
cache.rs

1//! A cache of validated tokens.
2//!
3//! Verifying a signature is the expensive part of authenticating a request,
4//! and a client typically sends the same token on every request until it
5//! expires. Caching the result turns that into a map lookup.
6//!
7//! The cache is keyed by the token itself rather than by a digest of it. A
8//! non-cryptographic hash would let two different tokens collide onto one
9//! entry, which is an authentication bypass, and a cryptographic one costs
10//! about what it saves. The token is already in memory for the length of the
11//! request either way.
12
13use std::collections::HashMap;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::Mutex;
16// `std::time` natively; on wasm32, where `Instant::now()` and
17// `SystemTime::now()` panic, the JavaScript host's clock.
18use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19
20use crate::{extract_bearer_token, validate_token, AuthResult, JwtConfig, JwtError};
21
22/// How many validated tokens to hold before making room.
23///
24/// A bound is the point: without one, anything that can present tokens can
25/// grow this map until the process dies.
26const CAPACITY: usize = 10_000;
27
28struct Entry {
29    result: AuthResult,
30    expires: Instant,
31}
32
33/// A bounded cache of validated tokens.
34pub struct JwtCache {
35    entries: Mutex<HashMap<String, Entry>>,
36    max_lifetime: Duration,
37    /// The [`crate::JwtKeys::generation`] every entry was verified under.
38    ///
39    /// Read and written only while `entries` is locked, so the two change
40    /// together; atomic only so that `JwtCache` stays `Sync` without a second
41    /// lock. When a later set of keys appears, every entry was verified
42    /// against a set that is no longer in force -- possibly by a key that has
43    /// just been withdrawn -- and they are all forgotten. Only ever moves
44    /// forward: see [`Self::get_under`].
45    keys_generation: AtomicU64,
46    // How many lookups found a live entry, and how many had to verify. Plain
47    // counters rather than a metric, so this crate stays free of any
48    // telemetry dependency and builds for a Worker as before; a host that
49    // exports metrics reads them with `stats`. Relaxed increments beside a
50    // mutex every lookup already takes: the cost is lost in the lock's.
51    hits: AtomicU64,
52    misses: AtomicU64,
53}
54
55/// Whether a lookup in a [`JwtCache`] was answered from it.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum CacheOutcome {
59    /// A live entry for the token answered, and no signature was checked.
60    Hit,
61    /// The token was verified, whatever the verdict, and cached if it could
62    /// be.
63    Miss,
64    /// Nothing to look up: the request carried no token, or its
65    /// `Authorization` header was not a bearer token at all. Not counted as
66    /// either of the others, so a server mostly answering anonymous requests
67    /// does not read as one whose cache never hits.
68    Bypassed,
69}
70
71impl CacheOutcome {
72    /// `hit`, `miss` or `bypassed`: the value a span or a metric records.
73    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/// How many lookups a [`JwtCache`] has answered, since it was made.
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84#[non_exhaustive]
85pub struct CacheStats {
86    /// Answered from the cache.
87    pub hits: u64,
88    /// Verified, because nothing live was cached for the token.
89    pub misses: u64,
90}
91
92impl JwtCache {
93    /// Build a cache, or `None` if the configuration turns it off.
94    ///
95    /// A zero lifetime disables it, as in PostgREST -- an entry that expires
96    /// the moment it is written is only overhead.
97    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    /// Forget every token held.
111    ///
112    /// Done by the cache itself whenever the key set it is used with changes;
113    /// for a host that wants to revoke for some other reason.
114    pub fn clear(&self) {
115        self.entries
116            .lock()
117            .unwrap_or_else(|e| e.into_inner())
118            .clear();
119    }
120
121    /// The lookups answered so far, for a host to export as a metric.
122    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    /// Authenticate, consulting the cache.
130    ///
131    /// Falls through to [`crate::authenticate`] for anything not cacheable: a
132    /// request with no token at all, and a token already at or past its own
133    /// expiry.
134    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    /// [`authenticate`](Self::authenticate), saying whether the cache
143    /// answered.
144    ///
145    /// A separate method rather than a change to that one's return type, so
146    /// that every caller of it builds unchanged. The verdict is the same
147    /// either way; the outcome is for a span or a metric to record.
148    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        // Read before the token is verified, and checked again when the entry
162        // is written: a set replaced in between was not the one it was
163        // verified against.
164        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    /// Verify a token nothing live was cached for, and cache the result if
178    /// it may be, unless the keys have moved on from `generation` meanwhile.
179    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        // Never hold an entry past the token's own expiry: doing so would keep
188        // answering with a token the issuer has already ended. `exp` bounds the
189        // lifetime, it does not extend it.
190        let ttl = match remaining_lifetime(&result) {
191            // No `exp` at all. Such a token does not expire, so the configured
192            // maximum is the only bound there is.
193            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            // An `exp` we cannot read is not the same as no `exp`, and must not
197            // fall through to the configured maximum: that would keep serving
198            // a token for up to an hour after it expired. Refuse to cache and
199            // let every request verify it.
200            Expiry::Unreadable => return Ok(result),
201        };
202
203        self.insert_under(token.to_string(), result.clone(), ttl, generation);
204        Ok(result)
205    }
206
207    /// [`Self::get`], first forgetting everything if `generation` is a later
208    /// set of keys than the entries were verified under.
209    ///
210    /// An earlier `generation` changes nothing: it was read by a request that
211    /// began before the keys were replaced. Moving the cache back to it would
212    /// let another such request, still verifying against the old set, cache
213    /// what that set -- and perhaps a key just withdrawn from it -- admitted.
214    /// The entries already there were verified under the later set, and
215    /// answer it as well as any.
216    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    /// [`Self::insert`], unless the entries are by now those of another
226    /// generation of keys -- the token was verified against a set that has
227    /// since been replaced, and is verified again next time.
228    ///
229    /// Checked under the same lock the entry is written under, so a request
230    /// that saw the new keys -- and cleared the entries -- cannot have an
231    /// entry from the old ones written after it.
232    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            // Everything still live and still at capacity. Dropping the lot is
272            // crude but bounded, and the cost of a miss is one verification.
273            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
288/// What the token's own `exp` claim says about how long it has left.
289enum Expiry {
290    /// No `exp` claim. The token does not expire.
291    Absent,
292    /// It expires this long from now; zero if it already has.
293    In(Duration),
294    /// There is an `exp`, and it is not a number this can read. Distinct from
295    /// `Absent` on purpose -- see the caller.
296    Unreadable,
297}
298
299/// Read `exp`, which RFC 7519 defines as a NumericDate and explicitly allows
300/// to carry a fraction. `as_i64` alone returns `None` for `1735689600.5`, and
301/// treating that as "no expiry" is how an expired token stays cached.
302fn 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        // Truncating towards zero is the conservative direction: it can only
310        // make the cached lifetime shorter.
311        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        // Already expired when written.
359        cache.insert("stale".to_string(), result, Duration::from_secs(0));
360        assert!(cache.get("stale").is_none());
361        // ...and reading it removed it.
362        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        // A token already expired has nothing left, so it is not cached.
382        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        // No `exp` at all is not "forever" -- the caller applies its maximum.
389        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        // RFC 7519 allows a NumericDate to carry a fraction, and `as_i64`
398        // returns None for one. Reading it as `Absent` would give the entry
399        // the full configured lifetime and keep serving the token for up to an
400        // hour after it expired.
401        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        // And one already past still reads as nothing left.
409        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        // The distinction is the whole point: `Absent` means "does not
419        // expire", and applying that to a token that does expire is the bug.
420        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        // Nothing was inserted for it, so a later lookup misses and the token
435        // is verified again rather than served from a stale entry.
436        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        // No token is neither: there was nothing to look up.
464        let (_, outcome) = cache.authenticate_with_outcome(None, &config);
465        assert_eq!(outcome, CacheOutcome::Bypassed);
466
467        // A refused token is a miss too -- it was looked up and verified.
468        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        // The old method answers alike, and is counted alike.
474        assert!(cache.authenticate(Some(&header), &config).is_ok());
475        assert_eq!(cache.stats().hits, 2);
476    }
477
478    /// Two requests that read the generation before the keys were replaced,
479    /// interleaved with one that read it after: the first must not put the
480    /// cache back on the old set, or the second -- verified against the old
481    /// set -- would be cached under it.
482    #[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        // A request that read `old`: it looks up, and finds what `new` cached.
492        assert!(cache.get_under("before", old).is_none());
493        assert!(cache.get_under("after", old).is_some());
494        // Another that read `old` and verified against the old set.
495        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    /// A set made later, by any handle, is a later generation.
501    #[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}