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