Skip to main content

cratefield_push_auth/
cache.rs

1//! Mint-once-reuse for provider tokens.
2//!
3//! Generalises the cache `cratefield-adapter-apns` grew for its provider
4//! JWT: Apple rejects regenerating one more than once per ~20 minutes and
5//! accepts it for up to 60, so it is minted once and reused. VAPID needs the
6//! same thing **per push-service origin** (a token's `aud` is the origin, so
7//! Chrome's and Firefox's are different tokens), and Google's exchanged
8//! bearer token needs it per service account. Hence the key.
9//!
10//! Two ways in, because not every token can be minted locally.
11//! [`CachedToken::get_or_mint`] mints under the lock, which is what makes a
12//! cold-cache race present Apple exactly one provider JWT. Google's bearer
13//! token is *exchanged* over HTTP instead, and no lock may be held across an
14//! `await`, so that path reads with [`CachedToken::cached`] and writes with
15//! [`CachedToken::store`] — the same map, the same invalidation, one extra
16//! exchange in a race.
17//!
18//! The cache holds a `Mutex` and is not request state: it is the adapter's
19//! own credential, shared across sends for its TTL (ADR 0007 allows a
20//! scoped, justified `Mutex`).
21
22use std::collections::HashMap;
23use std::hash::Hash;
24use std::time::Duration;
25
26use cratefield_core::Clock;
27
28// The adapter's own credential, held for its TTL (see module docs) — not
29// request state, which is what ADR 0007 bans. Named, so the allow sits on
30// this one item instead of the whole file, as the workspace `clippy.toml`
31// requires and `cratefield-adapter-sqlite` already does for its connection.
32#[allow(clippy::disallowed_types)]
33type Guarded<T> = std::sync::Mutex<T>;
34
35/// One cached token: the value, when it was minted or stored, and how long
36/// it may be reused from then.
37struct Entry {
38    token: String,
39    minted_unix: i64,
40    /// Seconds this entry may be reused for. The cache's own TTL for a
41    /// [`CachedToken::get_or_mint`]; the provider's stated lifetime, capped
42    /// by that TTL, for a [`CachedToken::store`].
43    ttl_secs: i64,
44}
45
46impl Entry {
47    /// Whether the entry is still inside its own lifetime at `now_unix`.
48    fn fresh_at(&self, now_unix: i64) -> bool {
49        now_unix.saturating_sub(self.minted_unix) < self.ttl_secs
50    }
51}
52
53/// Keyed mint-once-reuse with a [`Clock`]-driven TTL.
54///
55/// `K` is what the token varies by: `()` where an adapter has exactly one
56/// (APNs), a push-service origin for VAPID, a service-account id for Google.
57///
58/// The map is **bounded** at [`Self::CAPACITY`] (#136/#137: no unbounded
59/// resource on an isolate). A VAPID cache is keyed by push-service origin
60/// and a UnifiedPush endpoint is an arbitrary host, so without a bound the
61/// map grows for the isolate's lifetime and never sheds an expired entry.
62pub struct CachedToken<K = ()> {
63    ttl: Duration,
64    /// The adapter's own credential, shared across sends for its TTL — not
65    /// request state (see [`Guarded`]). A mutex and not an `RwLock`: the
66    /// miss path mints under the lock, so writers must exclude each other
67    /// anyway.
68    #[allow(clippy::disallowed_types)]
69    entries: Guarded<HashMap<K, Entry>>,
70}
71
72impl<K: Eq + Hash + Clone> CachedToken<K> {
73    /// How many keys the cache holds at once.
74    ///
75    /// Sized for the realistic key space: one entry for an APNs adapter,
76    /// one per push-service origin for VAPID (the browsers' four, plus the
77    /// UnifiedPush hosts a venture's users actually chose), one per service
78    /// account for Google. Past it, the **oldest-minted** entry is dropped —
79    /// mint time is what an entry records, and the oldest is the one closest
80    /// to needing a re-mint anyway. Eviction costs one extra mint, never
81    /// correctness.
82    pub const CAPACITY: usize = 64;
83
84    /// A cache that re-mints a token once it is older than `ttl`.
85    #[must_use]
86    pub fn new(ttl: Duration) -> Self {
87        Self {
88            ttl,
89            entries: Guarded::new(HashMap::new()),
90        }
91    }
92
93    /// How long a token is reused before it is re-minted.
94    #[must_use]
95    pub fn ttl(&self) -> Duration {
96        self.ttl
97    }
98
99    /// How many keys are cached right now, expired entries included until
100    /// the next insert prunes them.
101    ///
102    /// # Panics
103    ///
104    /// If the cache mutex was poisoned by a panic inside a previous mint.
105    #[must_use]
106    pub fn len(&self) -> usize {
107        self.entries.lock().expect("push-auth token cache").len()
108    }
109
110    /// Whether nothing is cached.
111    ///
112    /// # Panics
113    ///
114    /// If the cache mutex was poisoned by a panic inside a previous mint.
115    #[must_use]
116    pub fn is_empty(&self) -> bool {
117        self.len() == 0
118    }
119
120    /// The token for `key`, minting one if there is none or the cached one
121    /// has aged past the TTL. `mint` is handed the current UNIX time, which
122    /// is the same instant the entry is stamped with, so a token's `iat` can
123    /// never disagree with the cache's idea of its age.
124    ///
125    /// The clock is read once. A clock that goes backwards makes an entry
126    /// look fresh rather than making its age negative, which is the safe
127    /// direction: the worst case is one provider rejection, and every caller
128    /// of this already handles that with [`Self::invalidate`].
129    ///
130    /// **`mint` runs while the lock is held**, so a TTL miss mints exactly
131    /// once however many threads race into it. Dropping the lock first would
132    /// let two threads both mint and present two provider JWTs to Apple
133    /// milliseconds apart — `TooManyProviderTokenUpdates`, the precise
134    /// failure this cache exists to prevent. Signing takes microseconds and
135    /// the losing threads want that same token anyway, so the contention is
136    /// the point. `mint` must therefore not call back into this cache, and
137    /// is a plain `FnOnce` (not a future) so it cannot await while holding
138    /// the lock.
139    ///
140    /// # Panics
141    ///
142    /// If the cache mutex was poisoned by a panic inside a previous `mint`
143    /// — which now runs under the lock, so a panicking `mint` poisons the
144    /// cache for the isolate rather than only failing its own send.
145    pub fn get_or_mint(
146        &self,
147        clock: &dyn Clock,
148        key: &K,
149        mint: impl FnOnce(i64) -> String,
150    ) -> String {
151        let now_unix = clock.now().unix_timestamp();
152        let ttl = self.ttl_secs();
153        let mut entries = self.entries.lock().expect("push-auth token cache");
154        if let Some(cached) = entries.get(key)
155            && cached.fresh_at(now_unix)
156        {
157            return cached.token.clone();
158        }
159        let token = mint(now_unix);
160        Self::make_room(&mut entries, now_unix);
161        entries.insert(
162            key.clone(),
163            Entry {
164                token: token.clone(),
165                minted_unix: now_unix,
166                ttl_secs: ttl,
167            },
168        );
169        token
170    }
171
172    /// The cached token for `key`, or `None` when there is none or it has
173    /// aged past its lifetime. **Nothing is minted.**
174    ///
175    /// This is the read half of [`Self::get_or_mint`], for a token that
176    /// cannot be minted synchronously: Google's bearer token is *exchanged*
177    /// over HTTP, and `mint` is a plain `FnOnce` precisely so it can never
178    /// await while holding the lock. Pair it with [`Self::store`]:
179    ///
180    /// ```rust,ignore
181    /// if let Some(token) = cache.cached(clock, &key) { return Ok(token); }
182    /// let (token, lifetime) = exchange().await?;
183    /// cache.store(clock, &key, &token, lifetime);
184    /// ```
185    ///
186    /// The lock is therefore *not* held across the exchange, so two sends
187    /// racing a cold cache can each exchange one token. That costs a round
188    /// trip, never correctness: Google issues both and the second `store`
189    /// simply wins. It is the trade a synchronous `mint` does not have to
190    /// make — and the reason `get_or_mint` remains the right call wherever
191    /// minting is local.
192    ///
193    /// # Panics
194    ///
195    /// If the cache mutex was poisoned by a panic inside a previous mint.
196    #[must_use]
197    pub fn cached(&self, clock: &dyn Clock, key: &K) -> Option<String> {
198        let now_unix = clock.now().unix_timestamp();
199        let entries = self.entries.lock().expect("push-auth token cache");
200        entries
201            .get(key)
202            .filter(|cached| cached.fresh_at(now_unix))
203            .map(|cached| cached.token.clone())
204    }
205
206    /// Records `token` for `key`, minted now and reusable for `lifetime` —
207    /// or for the cache's own TTL where that is shorter, so the TTL stays a
208    /// ceiling however long a provider claims its token lives.
209    ///
210    /// A zero `lifetime` stores nothing that will ever be read back: the
211    /// entry is stale the instant it lands, which is the honest answer when
212    /// a provider hands over a token that expires inside the safety margin.
213    ///
214    /// # Panics
215    ///
216    /// If the cache mutex was poisoned by a panic inside a previous mint.
217    pub fn store(&self, clock: &dyn Clock, key: &K, token: impl Into<String>, lifetime: Duration) {
218        let now_unix = clock.now().unix_timestamp();
219        let ttl = self
220            .ttl_secs()
221            .min(i64::try_from(lifetime.as_secs()).unwrap_or(i64::MAX));
222        let mut entries = self.entries.lock().expect("push-auth token cache");
223        Self::make_room(&mut entries, now_unix);
224        entries.insert(
225            key.clone(),
226            Entry {
227                token: token.into(),
228                minted_unix: now_unix,
229                ttl_secs: ttl,
230            },
231        );
232    }
233
234    /// The cache's own TTL in whole seconds.
235    fn ttl_secs(&self) -> i64 {
236        i64::try_from(self.ttl.as_secs()).unwrap_or(i64::MAX)
237    }
238
239    /// Keeps the map inside [`Self::CAPACITY`] before one more entry goes in:
240    /// every entry past its TTL is dropped (it would be re-minted on its next
241    /// use regardless), and if that is not enough the oldest-minted entries
242    /// go until there is room.
243    fn make_room(entries: &mut HashMap<K, Entry>, now_unix: i64) {
244        entries.retain(|_, minted| minted.fresh_at(now_unix));
245        while entries.len() >= Self::CAPACITY {
246            let Some(oldest) = entries
247                .iter()
248                .min_by_key(|(_, minted)| minted.minted_unix)
249                .map(|(key, _)| key.clone())
250            else {
251                break;
252            };
253            entries.remove(&oldest);
254        }
255    }
256
257    /// Drops the token for `key`, so the next [`Self::get_or_mint`] re-mints
258    /// — what an adapter does when the provider says the token expired.
259    ///
260    /// # Panics
261    ///
262    /// If the cache mutex was poisoned by a panic inside a previous mint.
263    pub fn invalidate(&self, key: &K) {
264        self.entries
265            .lock()
266            .expect("push-auth token cache")
267            .remove(key);
268    }
269
270    /// Drops every cached token.
271    ///
272    /// # Panics
273    ///
274    /// If the cache mutex was poisoned by a panic inside a previous mint.
275    pub fn clear(&self) {
276        self.entries.lock().expect("push-auth token cache").clear();
277    }
278}
279
280impl<K: Eq + Hash + Clone> std::fmt::Debug for CachedToken<K> {
281    /// Never prints a token.
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        let entries = self
284            .entries
285            .lock()
286            .map_or(0, |entries: std::sync::MutexGuard<'_, _>| entries.len());
287        f.debug_struct("CachedToken")
288            .field("ttl", &self.ttl)
289            .field("entries", &entries)
290            .finish()
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
298
299    /// A fake clock the test advances by hand.
300    struct StepClock(AtomicI64);
301    impl StepClock {
302        fn at(secs: i64) -> Self {
303            Self(AtomicI64::new(secs))
304        }
305        fn advance(&self, secs: i64) {
306            self.0.fetch_add(secs, Ordering::Relaxed);
307        }
308    }
309    #[async_trait::async_trait]
310    impl Clock for StepClock {
311        fn now(&self) -> time::OffsetDateTime {
312            time::OffsetDateTime::from_unix_timestamp(self.0.load(Ordering::Relaxed))
313                .expect("in range")
314        }
315    }
316
317    /// Mints `token-<n>` and counts how often it was called.
318    #[derive(Default)]
319    struct Minter(AtomicUsize);
320    impl Minter {
321        fn mint(&self) -> impl FnOnce(i64) -> String + '_ {
322            move |now| {
323                let n = self.0.fetch_add(1, Ordering::Relaxed);
324                format!("token-{n}-at-{now}")
325            }
326        }
327        fn count(&self) -> usize {
328            self.0.load(Ordering::Relaxed)
329        }
330    }
331
332    #[test]
333    fn reuses_within_the_ttl_and_remints_after() {
334        let clock = StepClock::at(1_000);
335        let minter = Minter::default();
336        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
337
338        let first = cache.get_or_mint(&clock, &(), minter.mint());
339        assert_eq!(first, "token-0-at-1000");
340
341        clock.advance(2_400);
342        assert_eq!(cache.get_or_mint(&clock, &(), minter.mint()), first);
343        assert_eq!(minter.count(), 1, "reused within the TTL");
344
345        clock.advance(1_200);
346        let second = cache.get_or_mint(&clock, &(), minter.mint());
347        assert_ne!(second, first, "re-minted past the TTL");
348        assert_eq!(minter.count(), 2);
349    }
350
351    #[test]
352    fn invalidate_forces_a_remint() {
353        let clock = StepClock::at(0);
354        let minter = Minter::default();
355        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
356
357        let first = cache.get_or_mint(&clock, &(), minter.mint());
358        cache.invalidate(&());
359        // Same instant, so only the mint counter can tell the two apart.
360        let second = cache.get_or_mint(&clock, &(), minter.mint());
361        assert_ne!(second, first);
362        assert_eq!(minter.count(), 2);
363    }
364
365    #[test]
366    fn a_key_gets_its_own_token() {
367        let clock = StepClock::at(0);
368        let minter = Minter::default();
369        let cache: CachedToken<String> = CachedToken::new(Duration::from_mins(50));
370
371        let chrome = "https://fcm.googleapis.com".to_owned();
372        let firefox = "https://updates.push.services.mozilla.com".to_owned();
373        let a = cache.get_or_mint(&clock, &chrome, minter.mint());
374        let b = cache.get_or_mint(&clock, &firefox, minter.mint());
375        assert_ne!(a, b, "VAPID `aud` differs per push service");
376        assert_eq!(minter.count(), 2);
377
378        // Each is cached under its own key...
379        assert_eq!(cache.get_or_mint(&clock, &chrome, minter.mint()), a);
380        assert_eq!(cache.get_or_mint(&clock, &firefox, minter.mint()), b);
381        assert_eq!(minter.count(), 2);
382
383        // ...and invalidating one leaves the other alone.
384        cache.invalidate(&chrome);
385        assert_ne!(cache.get_or_mint(&clock, &chrome, minter.mint()), a);
386        assert_eq!(cache.get_or_mint(&clock, &firefox, minter.mint()), b);
387        assert_eq!(minter.count(), 3);
388
389        cache.clear();
390        assert_ne!(cache.get_or_mint(&clock, &firefox, minter.mint()), b);
391    }
392
393    /// Two threads racing an empty cache must mint **once**: Apple counts a
394    /// second provider JWT within ~20 minutes as
395    /// `TooManyProviderTokenUpdates` and rejects it, which is the whole
396    /// reason this cache exists.
397    #[test]
398    fn a_race_on_a_cold_cache_mints_exactly_once() {
399        use std::sync::Barrier;
400        use std::sync::atomic::AtomicUsize;
401
402        const THREADS: usize = 8;
403
404        let clock = StepClock::at(1_000);
405        let mints = AtomicUsize::new(0);
406        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
407        let gate = Barrier::new(THREADS);
408
409        let tokens: Vec<String> = std::thread::scope(|scope| {
410            let handles: Vec<_> = (0..THREADS)
411                .map(|_| {
412                    scope.spawn(|| {
413                        gate.wait();
414                        cache.get_or_mint(&clock, &(), |now| {
415                            let n = mints.fetch_add(1, Ordering::SeqCst);
416                            // Widen the window a lock-free miss would race in,
417                            // so the unguarded version fails every run rather
418                            // than one in a hundred.
419                            std::thread::sleep(std::time::Duration::from_millis(20));
420                            format!("token-{n}-at-{now}")
421                        })
422                    })
423                })
424                .collect();
425            handles
426                .into_iter()
427                .map(|handle| handle.join().expect("thread"))
428                .collect()
429        });
430
431        assert_eq!(
432            mints.load(Ordering::SeqCst),
433            1,
434            "a cold-cache race must present one provider token, not {THREADS}"
435        );
436        assert!(
437            tokens.windows(2).all(|pair| pair[0] == pair[1]),
438            "every racing caller gets the same token: {tokens:?}"
439        );
440    }
441
442    #[test]
443    fn expired_entries_are_pruned_and_the_map_is_bounded() {
444        let clock = StepClock::at(0);
445        let minter = Minter::default();
446        let cache: CachedToken<String> = CachedToken::new(Duration::from_mins(50));
447
448        // Expired entries do not accumulate: a VAPID cache is keyed by
449        // push-service origin, and a UnifiedPush endpoint is an arbitrary
450        // host, so nothing else would ever remove them.
451        for n in 0..10 {
452            cache.get_or_mint(&clock, &format!("https://push-{n}.example"), minter.mint());
453        }
454        assert_eq!(cache.len(), 10);
455        clock.advance(4_000); // past the TTL for all ten
456        cache.get_or_mint(&clock, &"https://fresh.example".to_owned(), minter.mint());
457        assert_eq!(cache.len(), 1, "the ten expired entries were pruned");
458
459        // And the live set is bounded, however many distinct origins arrive.
460        for n in 0..(CachedToken::<String>::CAPACITY * 2) {
461            cache.get_or_mint(&clock, &format!("https://live-{n}.example"), minter.mint());
462            assert!(
463                cache.len() <= CachedToken::<String>::CAPACITY,
464                "cache grew past its bound at {n}: {}",
465                cache.len()
466            );
467        }
468        assert_eq!(cache.len(), CachedToken::<String>::CAPACITY);
469        assert!(!cache.is_empty());
470    }
471
472    /// The async half: a token that is *exchanged* over HTTP cannot be
473    /// minted under the lock, so it is read with `cached` and written with
474    /// `store`.
475    #[test]
476    fn cached_reads_without_minting_and_store_writes() {
477        let clock = StepClock::at(1_000);
478        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(55));
479
480        assert_eq!(cache.cached(&clock, &()), None, "nothing cached yet");
481        assert!(cache.is_empty(), "a miss must not insert anything");
482
483        cache.store(&clock, &(), "ya29.bearer", Duration::from_mins(55));
484        assert_eq!(cache.cached(&clock, &()), Some("ya29.bearer".to_owned()));
485
486        clock.advance(3_299);
487        assert_eq!(cache.cached(&clock, &()), Some("ya29.bearer".to_owned()));
488        clock.advance(1);
489        assert_eq!(cache.cached(&clock, &()), None, "past its lifetime");
490    }
491
492    #[test]
493    fn a_stored_lifetime_is_capped_by_the_caches_own_ttl() {
494        let clock = StepClock::at(0);
495        let cache: CachedToken<()> = CachedToken::new(Duration::from_secs(600));
496
497        // A provider claiming an hour does not get an hour: the TTL is the
498        // ceiling.
499        cache.store(&clock, &(), "long", Duration::from_secs(3_600));
500        clock.advance(600);
501        assert_eq!(cache.cached(&clock, &()), None, "capped at the cache TTL");
502
503        // ...and a shorter provider lifetime wins over the TTL.
504        cache.store(&clock, &(), "short", Duration::from_secs(30));
505        clock.advance(30);
506        assert_eq!(cache.cached(&clock, &()), None, "the provider said 30s");
507
508        // A zero lifetime is never read back.
509        cache.store(&clock, &(), "already-stale", Duration::ZERO);
510        assert_eq!(cache.cached(&clock, &()), None);
511    }
512
513    #[test]
514    fn invalidate_drops_a_stored_token_too() {
515        let clock = StepClock::at(0);
516        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
517        cache.store(&clock, &(), "ya29.bearer", Duration::from_mins(55));
518        cache.invalidate(&());
519        assert_eq!(
520            cache.cached(&clock, &()),
521            None,
522            "a 401 drops the cache so the next send re-exchanges"
523        );
524    }
525
526    #[test]
527    fn stored_entries_are_pruned_and_bounded_like_minted_ones() {
528        let clock = StepClock::at(0);
529        let cache: CachedToken<String> = CachedToken::new(Duration::from_mins(50));
530        for n in 0..(CachedToken::<String>::CAPACITY * 2) {
531            cache.store(
532                &clock,
533                &format!("account-{n}"),
534                format!("token-{n}"),
535                Duration::from_mins(55),
536            );
537            assert!(
538                cache.len() <= CachedToken::<String>::CAPACITY,
539                "cache grew past its bound at {n}: {}",
540                cache.len()
541            );
542        }
543    }
544
545    /// The two halves share one map: a token stored by the async path is
546    /// returned by `get_or_mint` without re-minting, and vice versa.
547    #[test]
548    fn the_two_halves_share_one_entry() {
549        let clock = StepClock::at(0);
550        let minter = Minter::default();
551        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
552
553        cache.store(&clock, &(), "exchanged", Duration::from_mins(55));
554        assert_eq!(cache.get_or_mint(&clock, &(), minter.mint()), "exchanged");
555        assert_eq!(minter.count(), 0, "nothing was minted over a live entry");
556
557        cache.invalidate(&());
558        let signed = cache.get_or_mint(&clock, &(), minter.mint());
559        assert_eq!(cache.cached(&clock, &()), Some(signed));
560    }
561
562    #[test]
563    fn a_clock_that_goes_backwards_does_not_remint_forever() {
564        let clock = StepClock::at(10_000);
565        let minter = Minter::default();
566        let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
567
568        let first = cache.get_or_mint(&clock, &(), minter.mint());
569        clock.advance(-9_000);
570        assert_eq!(cache.get_or_mint(&clock, &(), minter.mint()), first);
571        assert_eq!(minter.count(), 1);
572    }
573}