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}