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::Mutex;
15// `std::time` natively; on wasm32, where `Instant::now()` and
16// `SystemTime::now()` panic, the JavaScript host's clock.
17use web_time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19use crate::{extract_bearer_token, validate_token, AuthResult, JwtConfig, JwtError};
20
21/// How many validated tokens to hold before making room.
22///
23/// A bound is the point: without one, anything that can present tokens can
24/// grow this map until the process dies.
25const CAPACITY: usize = 10_000;
26
27struct Entry {
28    result: AuthResult,
29    expires: Instant,
30}
31
32/// A bounded cache of validated tokens.
33pub struct JwtCache {
34    entries: Mutex<HashMap<String, Entry>>,
35    max_lifetime: Duration,
36}
37
38impl JwtCache {
39    /// Build a cache, or `None` if the configuration turns it off.
40    ///
41    /// A zero lifetime disables it, as in PostgREST -- an entry that expires
42    /// the moment it is written is only overhead.
43    pub fn new(enabled: bool, max_lifetime_secs: u64) -> Option<Self> {
44        if !enabled || max_lifetime_secs == 0 {
45            return None;
46        }
47        Some(Self {
48            entries: Mutex::new(HashMap::new()),
49            max_lifetime: Duration::from_secs(max_lifetime_secs),
50        })
51    }
52
53    /// Authenticate, consulting the cache.
54    ///
55    /// Falls through to [`crate::authenticate`] for anything not cacheable: a
56    /// request with no token at all, and a token already at or past its own
57    /// expiry.
58    pub fn authenticate(
59        &self,
60        auth_header: Option<&str>,
61        config: &JwtConfig,
62    ) -> Result<AuthResult, JwtError> {
63        let Some(header) = auth_header else {
64            return crate::authenticate(None, config);
65        };
66        let token = extract_bearer_token(header)?;
67
68        if let Some(hit) = self.get(token) {
69            return Ok(hit);
70        }
71
72        let result = validate_token(token, config)?;
73
74        // Never hold an entry past the token's own expiry: doing so would keep
75        // answering with a token the issuer has already ended. `exp` bounds the
76        // lifetime, it does not extend it.
77        let ttl = match remaining_lifetime(&result) {
78            // No `exp` at all. Such a token does not expire, so the configured
79            // maximum is the only bound there is.
80            Expiry::Absent => self.max_lifetime,
81            Expiry::In(remaining) if remaining.is_zero() => return Ok(result),
82            Expiry::In(remaining) => self.max_lifetime.min(remaining),
83            // An `exp` we cannot read is not the same as no `exp`, and must not
84            // fall through to the configured maximum: that would keep serving
85            // a token for up to an hour after it expired. Refuse to cache and
86            // let every request verify it.
87            Expiry::Unreadable => return Ok(result),
88        };
89
90        self.insert(token.to_string(), result.clone(), ttl);
91        Ok(result)
92    }
93
94    fn get(&self, token: &str) -> Option<AuthResult> {
95        let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
96        let entry = entries.get(token)?;
97        if entry.expires > Instant::now() {
98            return Some(entry.result.clone());
99        }
100        entries.remove(token);
101        None
102    }
103
104    fn insert(&self, token: String, result: AuthResult, ttl: Duration) {
105        let now = Instant::now();
106        let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
107
108        if entries.len() >= CAPACITY {
109            entries.retain(|_, e| e.expires > now);
110            // Everything still live and still at capacity. Dropping the lot is
111            // crude but bounded, and the cost of a miss is one verification.
112            if entries.len() >= CAPACITY {
113                entries.clear();
114            }
115        }
116
117        entries.insert(
118            token,
119            Entry {
120                result,
121                expires: now + ttl,
122            },
123        );
124    }
125}
126
127/// What the token's own `exp` claim says about how long it has left.
128enum Expiry {
129    /// No `exp` claim. The token does not expire.
130    Absent,
131    /// It expires this long from now; zero if it already has.
132    In(Duration),
133    /// There is an `exp`, and it is not a number this can read. Distinct from
134    /// `Absent` on purpose -- see the caller.
135    Unreadable,
136}
137
138/// Read `exp`, which RFC 7519 defines as a NumericDate and explicitly allows
139/// to carry a fraction. `as_i64` alone returns `None` for `1735689600.5`, and
140/// treating that as "no expiry" is how an expired token stays cached.
141fn remaining_lifetime(result: &AuthResult) -> Expiry {
142    let Some(exp) = result.claims.get("exp") else {
143        return Expiry::Absent;
144    };
145
146    let exp_secs = match exp.as_i64() {
147        Some(n) => n,
148        // Truncating towards zero is the conservative direction: it can only
149        // make the cached lifetime shorter.
150        None => match exp.as_f64() {
151            Some(f) if f.is_finite() => f.trunc() as i64,
152            _ => return Expiry::Unreadable,
153        },
154    };
155
156    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
157        return Expiry::Unreadable;
158    };
159
160    Expiry::In(Duration::from_secs(
161        exp_secs.saturating_sub(now.as_secs() as i64).max(0) as u64,
162    ))
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use serde_json::json;
169
170    fn result_with(claims: serde_json::Value) -> AuthResult {
171        AuthResult {
172            role: "web_user".to_string(),
173            claims: claims
174                .as_object()
175                .unwrap()
176                .iter()
177                .map(|(k, v)| (k.clone(), v.clone()))
178                .collect(),
179        }
180    }
181
182    #[test]
183    fn disabled_by_flag_or_by_zero_lifetime() {
184        assert!(JwtCache::new(false, 3600).is_none());
185        assert!(JwtCache::new(true, 0).is_none());
186        assert!(JwtCache::new(true, 1).is_some());
187    }
188
189    #[test]
190    fn a_hit_is_returned_and_an_expired_entry_is_not() {
191        let cache = JwtCache::new(true, 3600).unwrap();
192        let result = result_with(json!({"sub": "alice"}));
193
194        cache.insert("tok".to_string(), result.clone(), Duration::from_secs(60));
195        assert_eq!(cache.get("tok").unwrap().role, "web_user");
196
197        // Already expired when written.
198        cache.insert("stale".to_string(), result, Duration::from_secs(0));
199        assert!(cache.get("stale").is_none());
200        // ...and reading it removed it.
201        assert!(!cache.entries.lock().unwrap().contains_key("stale"));
202    }
203
204    fn now_secs() -> i64 {
205        SystemTime::now()
206            .duration_since(UNIX_EPOCH)
207            .unwrap()
208            .as_secs() as i64
209    }
210
211    #[test]
212    fn exp_bounds_the_lifetime_but_does_not_extend_it() {
213        let soon = now_secs() + 30;
214        let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
215            panic!("an integer exp should be readable");
216        };
217        assert!(remaining <= Duration::from_secs(30));
218        assert!(remaining > Duration::from_secs(25));
219
220        // A token already expired has nothing left, so it is not cached.
221        let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": soon - 600})))
222        else {
223            panic!("an integer exp should be readable");
224        };
225        assert!(none_left.is_zero());
226
227        // No `exp` at all is not "forever" -- the caller applies its maximum.
228        assert!(matches!(
229            remaining_lifetime(&result_with(json!({"sub": "alice"}))),
230            Expiry::Absent
231        ));
232    }
233
234    #[test]
235    fn a_fractional_exp_is_read_rather_than_treated_as_absent() {
236        // RFC 7519 allows a NumericDate to carry a fraction, and `as_i64`
237        // returns None for one. Reading it as `Absent` would give the entry
238        // the full configured lifetime and keep serving the token for up to an
239        // hour after it expired.
240        let soon = now_secs() as f64 + 30.5;
241        let Expiry::In(remaining) = remaining_lifetime(&result_with(json!({"exp": soon}))) else {
242            panic!("a fractional exp must not read as absent or unreadable");
243        };
244        assert!(remaining <= Duration::from_secs(31));
245        assert!(remaining > Duration::from_secs(25));
246
247        // And one already past still reads as nothing left.
248        let past = now_secs() as f64 - 600.5;
249        let Expiry::In(none_left) = remaining_lifetime(&result_with(json!({"exp": past}))) else {
250            panic!("a fractional exp must not read as absent or unreadable");
251        };
252        assert!(none_left.is_zero());
253    }
254
255    #[test]
256    fn an_exp_that_is_not_a_number_is_unreadable_not_absent() {
257        // The distinction is the whole point: `Absent` means "does not
258        // expire", and applying that to a token that does expire is the bug.
259        for value in [json!({"exp": "soon"}), json!({"exp": null})] {
260            assert!(
261                matches!(
262                    remaining_lifetime(&result_with(value.clone())),
263                    Expiry::Unreadable
264                ),
265                "{value:?}"
266            );
267        }
268    }
269
270    #[test]
271    fn a_token_with_an_unreadable_exp_is_not_cached() {
272        let cache = JwtCache::new(true, 3600).unwrap();
273        // Nothing was inserted for it, so a later lookup misses and the token
274        // is verified again rather than served from a stale entry.
275        assert!(cache.get("tok").is_none());
276        assert!(cache.entries.lock().unwrap().is_empty());
277    }
278
279    #[test]
280    fn the_cache_is_bounded() {
281        let cache = JwtCache::new(true, 3600).unwrap();
282        let result = result_with(json!({"sub": "alice"}));
283        for i in 0..CAPACITY + 50 {
284            cache.insert(format!("tok{i}"), result.clone(), Duration::from_secs(600));
285        }
286        assert!(cache.entries.lock().unwrap().len() <= CAPACITY);
287    }
288}