Skip to main content

toolkit_security/
internal_auth_cache.rs

1//! Short-lived positive (and brief negative) caching for platform-plane
2//! authentication.
3//!
4//! [`CachingInternalAuthenticator`] wraps any [`InternalAuthenticator`] with
5//! an in-memory, TTL-bounded cache. It exists because a remote validation
6//! backend (e.g. the Kubernetes `TokenReview` API) performs a live round-trip
7//! on every call — untenable on a hot gRPC/HTTP path where the same projected
8//! credential is presented on back-to-back requests
9//! (`cpt-cf-adr-platform-plane-auth`, decision 5).
10//!
11//! # Semantics
12//!
13//! - **Successful** validations are cached for up to `ttl`, clamped to the
14//!   credential's own remaining validity when it is a JWT carrying an `exp`
15//!   claim (see [`jwt_exp_claim`]) — a token with two seconds left is never
16//!   cached for the full configured `ttl`.
17//! - **Rejections** ([`InternalAuthNError::InvalidToken`]) are cached for a
18//!   short, fixed [`NEGATIVE_CACHE_TTL`] so a caller presenting no valid
19//!   credential cannot drive one backend round-trip per request, while a
20//!   token that becomes valid moments later is re-checked quickly.
21//! - Backend failures ([`InternalAuthNError::Unavailable`], `Other`) are
22//!   **never** cached: a transient outage is re-evaluated on the next call.
23//! - Concurrent misses for the **same** token are serialized behind a
24//!   per-token lock (single-flight), so a burst of calls carrying the same
25//!   credential collapses into one backend round-trip instead of N.
26//! - The cache key is a SHA-256 digest of the token, so no map here holds the
27//!   credential itself and an entry costs the same whatever the token's
28//!   length. A cryptographic digest rather than a fast hash because the input
29//!   is a credential: a collision would let one token answer for another.
30//! - The cache holds at most [`MAX_CACHE_ENTRIES`] distinct tokens. When full,
31//!   a single sweep reclaims any expired entries first (amortizing across the
32//!   inserts it makes room for); only if nothing is reclaimable — a burst of
33//!   distinct, individually-valid credentials — does it evict the entry
34//!   expiring soonest, rather than growing unbounded.
35
36use std::collections::{BTreeSet, HashMap};
37use std::sync::Arc;
38use std::sync::atomic::{AtomicU32, Ordering};
39use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
40
41use base64::Engine as _;
42use parking_lot::Mutex;
43use tokio::sync::Mutex as AsyncMutex;
44
45use crate::internal_auth::{InternalAuthNError, InternalAuthenticator, PlatformIdentity};
46
47/// Default time-to-live for a cached successful validation.
48///
49/// A conservative few seconds: long enough to collapse a burst of calls
50/// carrying the same token, short enough to keep the post-revocation
51/// acceptance window small.
52pub const DEFAULT_TOKEN_REVIEW_CACHE_TTL: Duration = Duration::from_secs(30);
53
54/// Upper bound accepted by [`CachingInternalAuthenticator::new`]. Caps how
55/// long a revoked or expired token can keep validating from cache, so a
56/// misconfiguration cannot widen the revocation window unboundedly.
57pub const MAX_TOKEN_REVIEW_CACHE_TTL: Duration = Duration::from_mins(5);
58
59/// Fixed TTL for a cached **rejection**. Deliberately short and
60/// non-configurable: it exists only to blunt a hot loop of fresh invalid
61/// tokens, not to widen any acceptance window.
62const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(1);
63
64/// Default deadline for the whole [`InternalAuthenticator::authenticate`]
65/// call, overridable per instance with
66/// [`CachingInternalAuthenticator::with_authentication_timeout`].
67///
68/// The name says "authentication", not "backend": the deadline covers the
69/// per-token single-flight lock wait as well as the backend round-trip. A
70/// backend call alone would not have been enough -- three concurrent callers
71/// presenting the same token against a hung backend each started their own
72/// timer only on reaching the front of the queue, so they finished 1x, 2x and
73/// 3x the deadline apart instead of each failing within one deadline of its
74/// own arrival. Bounding the whole call converts an indefinite hang, or an
75/// indefinite queue, into an `Unavailable` every caller can act on within the
76/// same window.
77///
78/// A request deadline, deliberately unrelated to the cache TTL: it is sized
79/// for how long a `TokenReview` round-trip (plus queueing behind it) may
80/// reasonably take, not for how long a cached answer stays usable.
81pub const DEFAULT_AUTHENTICATION_TIMEOUT: Duration = Duration::from_secs(10);
82
83/// Amortizes the expired-entry sweep: a full scan of the cache runs only
84/// every `SWEEP_INTERVAL`-th insert rather than on every single one.
85const SWEEP_INTERVAL: u32 = 32;
86
87/// Maximum number of distinct tokens held at once, bounding memory even
88/// under a sustained burst of distinct, individually-valid credentials
89/// (which TTL expiry alone never reclaims). Generous enough for realistic
90/// fleets of Kubernetes `ServiceAccount`s calling through a single
91/// authenticator instance.
92pub const MAX_CACHE_ENTRIES: usize = 10_000;
93
94/// `ttl` passed to [`CachingInternalAuthenticator::new`] was zero or exceeded
95/// [`MAX_TOKEN_REVIEW_CACHE_TTL`].
96#[derive(Debug, thiserror::Error)]
97#[error(
98    "internal-auth cache TTL must be > 0 and <= {MAX_TOKEN_REVIEW_CACHE_TTL:?}, got {actual:?}"
99)]
100pub struct InvalidCacheTtl {
101    actual: Duration,
102}
103
104/// A cache key: the SHA-256 digest of a credential, never the credential.
105///
106/// The map, the expiry index and the single-flight map are all keyed on this.
107/// Keying them on the token itself stored the credential two or three times
108/// over — at `MAX_CACHE_ENTRIES` entries and a header-sized token that ran to
109/// hundreds of megabytes per cache instance — and kept the plaintext alive for
110/// as long as the entry did. A digest is 32 bytes whatever the token's length,
111/// which also removes the only reason the cache had to refuse long tokens.
112///
113/// A cryptographic digest rather than a fast hash: the input is a credential,
114/// and a collision here would let one token answer for another.
115#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
116struct TokenKey([u8; 32]);
117
118impl TokenKey {
119    fn of(token: &str) -> Self {
120        use sha2::{Digest as _, Sha256};
121        Self(Sha256::digest(token.as_bytes()).into())
122    }
123}
124
125impl std::fmt::Debug for TokenKey {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        // Enough to correlate two log lines, not enough to be worth anything to
128        // whoever reads them.
129        write!(f, "TokenKey({:02x}{:02x}..)", self.0[0], self.0[1])
130    }
131}
132
133/// A cached validation outcome and the instant it stops applying.
134enum CacheEntry {
135    Valid {
136        identity: PlatformIdentity,
137        expires_at: Instant,
138    },
139    Rejected {
140        expires_at: Instant,
141    },
142}
143
144impl CacheEntry {
145    fn expires_at(&self) -> Instant {
146        match self {
147            Self::Valid { expires_at, .. } | Self::Rejected { expires_at } => *expires_at,
148        }
149    }
150}
151
152/// Outcome of a cache lookup.
153enum CacheLookup {
154    Valid(PlatformIdentity),
155    Rejected,
156    Miss,
157}
158
159/// The cache map, paired with an index of its entries ordered by expiry.
160///
161/// The index exists so that neither reclaiming expired entries nor choosing an
162/// eviction victim has to scan the map. That matters because every one of those
163/// operations runs under the cache mutex, on the authentication hot path: the
164/// previous whole-map `min_by_key` scan ran on *every* insert for as long as the
165/// cache stayed full of still-valid entries, which is exactly the sustained-load
166/// case, and it blocked every concurrent lookup while it ran.
167///
168/// The two collections are only consistent if they are updated together, so the
169/// map is private and every mutation goes through a method here.
170struct ExpiringCache {
171    entries: HashMap<TokenKey, CacheEntry>,
172    /// `(expires_at, token)` for every entry in `entries`. Ordered, so the
173    /// soonest-to-expire is the first element and expired entries are a prefix.
174    by_expiry: BTreeSet<(Instant, TokenKey)>,
175}
176
177impl ExpiringCache {
178    fn new() -> Self {
179        Self {
180            entries: HashMap::new(),
181            by_expiry: BTreeSet::new(),
182        }
183    }
184
185    fn len(&self) -> usize {
186        self.entries.len()
187    }
188
189    fn contains_key(&self, key: &TokenKey) -> bool {
190        self.entries.contains_key(key)
191    }
192
193    fn get(&self, key: &TokenKey) -> Option<&CacheEntry> {
194        self.entries.get(key)
195    }
196
197    fn insert(&mut self, key: TokenKey, entry: CacheEntry) {
198        let expires_at = entry.expires_at();
199        if let Some(previous) = self.entries.insert(key, entry) {
200            self.by_expiry.remove(&(previous.expires_at(), key));
201        }
202        self.by_expiry.insert((expires_at, key));
203    }
204
205    fn remove(&mut self, key: &TokenKey) {
206        if let Some(entry) = self.entries.remove(key) {
207            self.by_expiry.remove(&(entry.expires_at(), *key));
208        }
209    }
210
211    /// Drop every entry that has expired by `now`. Touches only the entries it
212    /// removes: they are a prefix of the index.
213    fn sweep_expired(&mut self, now: Instant) {
214        // `Instant` has no "minimum", so split at `now` instead: everything
215        // ordered before `(now, <all-zero key>)` expired at or before it.
216        let live = self.by_expiry.split_off(&(now, TokenKey([0; 32])));
217        let expired = std::mem::replace(&mut self.by_expiry, live);
218        for (_, key) in expired {
219            self.entries.remove(&key);
220        }
221    }
222
223    /// Drop the entry that expires soonest, which is the one needing
224    /// re-validation soonest anyway. `O(log n)`, no scan.
225    fn evict_soonest(&mut self) {
226        if let Some((expires_at, key)) = self.by_expiry.pop_first() {
227            self.entries.remove(&key);
228            debug_assert!(
229                !self.entries.contains_key(&key),
230                "index and map disagreed about {key:?} at {expires_at:?}"
231            );
232        }
233    }
234}
235
236/// What a credential's `exp` claim says, if anything.
237///
238/// An `Option<u64>` collapsed two different answers onto `None`: a credential
239/// carrying no `exp` because it is not a JWT, and a JWT whose `exp` could not
240/// be read. The first is cacheable for the full TTL; the second is not, because
241/// the one thing known about it is that it declares an expiry we cannot honour.
242enum ExpClaim {
243    /// Not a JWT (e.g. a shared secret). Nothing to clamp against.
244    NotJwt,
245    /// A JWT declaring this expiry, in seconds since the Unix epoch.
246    Expires(u64),
247    /// Shaped like a JWT, but `exp` is absent, not a number, or the payload did
248    /// not decode.
249    Unreadable,
250}
251
252/// Best-effort extraction of the `exp` (seconds since the Unix epoch) claim
253/// from a JWT, without verifying the signature.
254///
255/// The caller has already had the token's signature verified by the
256/// authentication backend (e.g. Kubernetes `TokenReview`); this is a plain
257/// base64 decode of the already-trusted payload, used only to avoid caching a
258/// validation past the credential's own expiry.
259fn jwt_exp_claim(token: &str) -> ExpClaim {
260    // Three dot-separated parts is what makes this a JWT rather than an opaque
261    // credential, and an opaque credential has no expiry to honour.
262    let mut parts = token.split('.');
263    let (Some(_header), Some(payload_b64), Some(_signature), None) =
264        (parts.next(), parts.next(), parts.next(), parts.next())
265    else {
266        return ExpClaim::NotJwt;
267    };
268
269    let Ok(payload) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64) else {
270        return ExpClaim::Unreadable;
271    };
272    let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload) else {
273        return ExpClaim::Unreadable;
274    };
275    match value.get("exp").and_then(serde_json::Value::as_u64) {
276        Some(exp) => ExpClaim::Expires(exp),
277        None => ExpClaim::Unreadable,
278    }
279}
280
281/// The instant a freshly validated `token` should stop being trusted from
282/// cache: whichever is sooner of `now + ttl` and the token's own `exp` claim
283/// (when present).
284///
285/// Every arithmetic step is checked. `exp` comes out of a base64 payload with
286/// no bound on its value, and both `SystemTime + Duration` and
287/// `Instant + Duration` panic on overflow — which would take down the platform
288/// authentication path rather than skip a clamp.
289fn clamped_expiry(token: &str, now: Instant, ttl: Duration) -> Instant {
290    let ttl_expiry = now.checked_add(ttl).unwrap_or(now);
291    let exp_secs = match jwt_exp_claim(token) {
292        ExpClaim::NotJwt => return ttl_expiry,
293        // A declared-but-unreadable expiry is not a licence to cache for the
294        // full TTL: expire immediately and re-validate on the next call.
295        ExpClaim::Unreadable => return now,
296        ExpClaim::Expires(exp) => exp,
297    };
298
299    let Some(exp_at) = UNIX_EPOCH.checked_add(Duration::from_secs(exp_secs)) else {
300        // An `exp` too far in the future to fit a `SystemTime` says nothing
301        // useful about when to stop trusting the token; fall back to the TTL.
302        return ttl_expiry;
303    };
304    let Ok(remaining) = exp_at.duration_since(SystemTime::now()) else {
305        // The token's own claim says it is already expired; do not extend
306        // trust in it at all.
307        return now;
308    };
309    let exp_expiry = now.checked_add(remaining).unwrap_or(ttl_expiry);
310    ttl_expiry.min(exp_expiry)
311}
312
313/// Wraps an [`InternalAuthenticator`] with a short-lived cache of both
314/// successful and rejected validations.
315///
316/// Construct it around the concrete validator and hand the wrapper to the
317/// transport layer as the `InternalAuthenticator`:
318///
319/// ```rust
320/// use std::time::Duration;
321/// use toolkit_security::{CachingInternalAuthenticator, InternalAuthNError, PlatformIdentity};
322///
323/// struct AlwaysOk;
324/// impl toolkit_security::InternalAuthenticator for AlwaysOk {
325///     async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
326///         Ok(PlatformIdentity::Shared { name: token.to_owned() })
327///     }
328/// }
329///
330/// # fn wire() -> Result<(), Box<dyn std::error::Error>> {
331/// let cached = CachingInternalAuthenticator::new(AlwaysOk, Duration::from_secs(30))?;
332/// # let _ = cached;
333/// # Ok(())
334/// # }
335/// ```
336pub struct CachingInternalAuthenticator<A> {
337    inner: A,
338    ttl: Duration,
339    /// Deadline for the whole `authenticate` call. Defaults to
340    /// [`DEFAULT_AUTHENTICATION_TIMEOUT`]; override with
341    /// [`with_authentication_timeout`](Self::with_authentication_timeout).
342    authentication_timeout: Duration,
343    cache: Mutex<ExpiringCache>,
344    /// Per-token single-flight locks: concurrent misses for the same token
345    /// serialize here instead of each issuing a backend call.
346    inflight: Mutex<HashMap<TokenKey, Arc<AsyncMutex<()>>>>,
347    sweep_counter: AtomicU32,
348}
349
350impl<A> std::fmt::Debug for CachingInternalAuthenticator<A> {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        f.debug_struct("CachingInternalAuthenticator")
353            .field("ttl", &self.ttl)
354            .finish_non_exhaustive()
355    }
356}
357
358impl<A> CachingInternalAuthenticator<A> {
359    /// Wrap `inner`, caching successful validations for up to `ttl` (clamped
360    /// to the credential's own expiry when it is a JWT) and rejections for a
361    /// short fixed window.
362    ///
363    /// # Errors
364    /// Returns [`InvalidCacheTtl`] if `ttl` is zero or exceeds
365    /// [`MAX_TOKEN_REVIEW_CACHE_TTL`].
366    pub fn new(inner: A, ttl: Duration) -> Result<Self, InvalidCacheTtl> {
367        if ttl == Duration::ZERO || ttl > MAX_TOKEN_REVIEW_CACHE_TTL {
368            return Err(InvalidCacheTtl { actual: ttl });
369        }
370        Ok(Self {
371            inner,
372            ttl,
373            authentication_timeout: DEFAULT_AUTHENTICATION_TIMEOUT,
374            cache: Mutex::new(ExpiringCache::new()),
375            inflight: Mutex::new(HashMap::new()),
376            sweep_counter: AtomicU32::new(0),
377        })
378    }
379
380    /// Wrap `inner` with the [`DEFAULT_TOKEN_REVIEW_CACHE_TTL`].
381    #[must_use]
382    pub fn with_default_ttl(inner: A) -> Self {
383        Self {
384            inner,
385            ttl: DEFAULT_TOKEN_REVIEW_CACHE_TTL,
386            authentication_timeout: DEFAULT_AUTHENTICATION_TIMEOUT,
387            cache: Mutex::new(ExpiringCache::new()),
388            inflight: Mutex::new(HashMap::new()),
389            sweep_counter: AtomicU32::new(0),
390        }
391    }
392
393    /// Override the deadline applied to the whole `authenticate` call
394    /// (including the per-token lock wait), replacing
395    /// [`DEFAULT_AUTHENTICATION_TIMEOUT`].
396    #[must_use]
397    pub fn with_authentication_timeout(mut self, timeout: Duration) -> Self {
398        self.authentication_timeout = timeout;
399        self
400    }
401
402    /// Look up a still-applicable cached outcome for `token`, evicting it
403    /// immediately if found stale (rather than waiting for the next sweep).
404    ///
405    /// Holds the lock only for the duration of the map access (never across
406    /// an `await`), so the returned future stays `Send`.
407    fn lookup(&self, key: &TokenKey, now: Instant) -> CacheLookup {
408        let mut cache = self.cache.lock();
409        let Some(entry) = cache.get(key) else {
410            return CacheLookup::Miss;
411        };
412        if entry.expires_at() <= now {
413            cache.remove(key);
414            return CacheLookup::Miss;
415        }
416        match entry {
417            CacheEntry::Valid { identity, .. } => CacheLookup::Valid(identity.clone()),
418            CacheEntry::Rejected { .. } => CacheLookup::Rejected,
419        }
420    }
421
422    /// Insert `entry` under `token`, amortizing the expired-entry sweep over
423    /// [`SWEEP_INTERVAL`] inserts and enforcing [`MAX_CACHE_ENTRIES`] when a
424    /// new token would exceed it.
425    ///
426    /// Both the sweep and the eviction go through [`ExpiringCache`]'s expiry
427    /// index, so each touches only the entries it actually removes. Under
428    /// sustained load with a cache full of still-valid entries — the case that
429    /// matters, since TTL expiry alone never reclaims anything there — this is
430    /// the difference between a whole-map scan per insert and an `O(log n)`
431    /// lookup, all of it under the mutex every concurrent lookup needs.
432    fn insert(&self, key: TokenKey, entry: CacheEntry, now: Instant) {
433        let mut cache = self.cache.lock();
434        let count = self.sweep_counter.fetch_add(1, Ordering::Relaxed) + 1;
435        if count.is_multiple_of(SWEEP_INTERVAL) {
436            cache.sweep_expired(now);
437        }
438        if cache.len() >= MAX_CACHE_ENTRIES && !cache.contains_key(&key) {
439            // Reclaim what has expired before evicting anything still valid.
440            cache.sweep_expired(now);
441            if cache.len() >= MAX_CACHE_ENTRIES {
442                // Every entry is still valid, so caching is now costing us a
443                // live entry per insert and the backend sees a call for each
444                // one evicted. Nothing else reports that, and the symptom
445                // downstream is a surge of TokenReview traffic that looks like
446                // a backend problem rather than cache saturation.
447                tracing::warn!(
448                    entries = cache.len(),
449                    max_entries = MAX_CACHE_ENTRIES,
450                    "internal-auth cache is full of unexpired entries; evicting a live entry"
451                );
452                cache.evict_soonest();
453            }
454        }
455        cache.insert(key, entry);
456    }
457
458    /// Get-or-create the per-token single-flight lock.
459    fn token_lock(&self, key: TokenKey) -> Arc<AsyncMutex<()>> {
460        let mut inflight = self.inflight.lock();
461        Arc::clone(
462            inflight
463                .entry(key)
464                .or_insert_with(|| Arc::new(AsyncMutex::new(()))),
465        )
466    }
467
468    /// Drop the per-token lock from the map once nothing else references it,
469    /// so `inflight` does not grow unboundedly over the process lifetime.
470    fn release_token_lock(&self, key: &TokenKey, lock: &Arc<AsyncMutex<()>>) {
471        let mut inflight = self.inflight.lock();
472        // 2 = the map's own clone + `lock` here; anything higher means
473        // another waiter still holds a clone.
474        if Arc::strong_count(lock) <= 2 {
475            inflight.remove(key);
476        }
477    }
478}
479
480/// Releases the `inflight` entry on drop, so cancellation (not just a normal
481/// return) still cleans it up. Borrows `lock` rather than cloning it, so it
482/// doesn't skew `release_token_lock`'s `Arc::strong_count` check.
483struct ReleaseTokenLockOnDrop<'a, A> {
484    owner: &'a CachingInternalAuthenticator<A>,
485    key: TokenKey,
486    lock: &'a Arc<AsyncMutex<()>>,
487}
488
489impl<A> Drop for ReleaseTokenLockOnDrop<'_, A> {
490    fn drop(&mut self) {
491        self.owner.release_token_lock(&self.key, self.lock);
492    }
493}
494
495impl<A: InternalAuthenticator> CachingInternalAuthenticator<A> {
496    /// The implementation behind [`InternalAuthenticator::authenticate`],
497    /// parameterized on the "current" instant used for the lookup / miss
498    /// phases.
499    ///
500    /// Split out so a test can drive a deterministic TTL-expiry check (e.g.
501    /// `now + ttl + 1ms`) instead of a real `tokio::time::sleep` — this
502    /// crate's `Instant`-based cache cannot be virtualized by
503    /// `tokio::time::pause`. `authenticate` is simply the
504    /// `Instant::now()`-sampling production wrapper.
505    ///
506    /// The instant used to timestamp a freshly-stored entry is still
507    /// re-sampled internally *after* the backend round-trip (never derived
508    /// from `now`) so a slow backend call never erodes the effective TTL.
509    ///
510    /// cancel-safe: dropping this future early loses only a would-be cache
511    /// insert or single-flight slot — the lock and its `inflight` entry are
512    /// still released, via `AsyncMutex`'s guard and [`ReleaseTokenLockOnDrop`].
513    async fn authenticate_at(
514        &self,
515        token: &str,
516        now: Instant,
517    ) -> Result<PlatformIdentity, InternalAuthNError> {
518        // Every map is keyed on the digest, so the credential's length no longer
519        // decides what the cache costs and there is nothing to bypass.
520        let key = TokenKey::of(token);
521
522        match self.lookup(&key, now) {
523            CacheLookup::Valid(identity) => return Ok(identity),
524            CacheLookup::Rejected => return Err(InternalAuthNError::InvalidToken),
525            CacheLookup::Miss => {}
526        }
527
528        // Single-flight: serialize concurrent misses for the same token so a
529        // burst of calls collapses into one backend round-trip.
530        let lock = self.token_lock(key);
531        // Constructed *before* the lock is acquired, not after: a caller
532        // cancelled while still waiting for the lock -- not yet holding it --
533        // must still release its `inflight` registration. With the guard built
534        // only after a successful `.lock().await`, a waiter cancelled before
535        // that point had no guard at all, so its registration was never
536        // cleaned up and outlived every other reference to it: an unbounded,
537        // per-cancelled-token leak in `inflight`.
538        let _release = ReleaseTokenLockOnDrop {
539            owner: self,
540            key,
541            lock: &lock,
542        };
543        let _guard = lock.lock().await;
544
545        // Another caller may have populated the cache while this one waited.
546        // Refresh the cutoff: `now` predates the lock wait, so a stale value
547        // could serve an entry that expired during it. Later of the injected
548        // instant and real time keeps a test's deterministic `now` dominant.
549        let now = now.max(Instant::now());
550        match self.lookup(&key, now) {
551            CacheLookup::Valid(identity) => return Ok(identity),
552            CacheLookup::Rejected => return Err(InternalAuthNError::InvalidToken),
553            CacheLookup::Miss => {}
554        }
555
556        // Unbounded here on purpose: the deadline lives in `authenticate`, so
557        // it covers the wait for the lock above as well as this call. A second
558        // timeout here would restart the clock for whoever just acquired the
559        // lock, which is exactly the queueing behaviour the outer one removes.
560        //
561        // A caller abandoned at the deadline simply drops this future; nothing
562        // is cached for it, which is right — a timeout says nothing about
563        // whether the credential is valid, and caching it would turn one slow
564        // call into a fixed window of denials.
565        let result = self.inner.authenticate(token).await;
566        // Re-sampled *after* the backend round-trip: sampling before it would
567        // shrink the effective TTL by however long the call took.
568        let stored_at = Instant::now();
569
570        match result {
571            Ok(identity) => {
572                let expires_at = clamped_expiry(token, stored_at, self.ttl);
573                self.insert(
574                    key,
575                    CacheEntry::Valid {
576                        identity: identity.clone(),
577                        expires_at,
578                    },
579                    stored_at,
580                );
581                Ok(identity)
582            }
583            Err(InternalAuthNError::InvalidToken) => {
584                self.insert(
585                    key,
586                    CacheEntry::Rejected {
587                        expires_at: stored_at + NEGATIVE_CACHE_TTL,
588                    },
589                    stored_at,
590                );
591                Err(InternalAuthNError::InvalidToken)
592            }
593            // Backend outage / unexpected failure: never cached, so a
594            // recovery or a later attempt is re-evaluated immediately.
595            Err(err) => Err(err),
596        }
597    }
598}
599
600impl<A: InternalAuthenticator> InternalAuthenticator for CachingInternalAuthenticator<A> {
601    async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
602        // One deadline for the whole call, measured from *this* caller's
603        // arrival, and it covers the wait for the per-token lock as well as the
604        // backend round-trip.
605        //
606        // Bounding only the backend call was not enough: the lock is taken
607        // first, so three concurrent callers presenting the same token against
608        // a hung backend each started their own timer on reaching the front of
609        // the queue and finished at roughly 10s, 20s and 30s. Now each of them
610        // fails within one deadline of arriving.
611        //
612        // Cancelling here is safe: the lock guard and `ReleaseTokenLockOnDrop`
613        // both clean up on drop, so an abandoned attempt leaves no lock held
614        // and no `inflight` entry behind.
615        match tokio::time::timeout(
616            self.authentication_timeout,
617            self.authenticate_at(token, Instant::now()),
618        )
619        .await
620        {
621            Ok(result) => result,
622            Err(_elapsed) => Err(InternalAuthNError::Unavailable),
623        }
624    }
625}
626
627#[cfg(test)]
628#[cfg_attr(coverage_nightly, coverage(off))]
629mod tests {
630    use super::*;
631    use std::sync::atomic::AtomicUsize;
632
633    /// Counts backend calls and can be flipped between success and one of two
634    /// failure modes so tests can assert exactly when the wrapped
635    /// authenticator is consulted.
636    struct CountingAuth {
637        calls: AtomicUsize,
638        mode: Mutex<Mode>,
639        /// Artificial delay before returning, to widen the single-flight
640        /// window so a concurrent waiter genuinely blocks on the per-token
641        /// lock instead of finding the cache already populated at its first
642        /// (pre-lock) check.
643        delay: Mutex<Duration>,
644    }
645
646    #[derive(Clone, Copy, PartialEq, Eq)]
647    enum Mode {
648        Succeed,
649        Unavailable,
650        Invalid,
651    }
652
653    impl CountingAuth {
654        fn new() -> Self {
655            Self {
656                calls: AtomicUsize::new(0),
657                mode: Mutex::new(Mode::Succeed),
658                delay: Mutex::new(Duration::ZERO),
659            }
660        }
661        fn calls(&self) -> usize {
662            self.calls.load(Ordering::SeqCst)
663        }
664        fn set_mode(&self, mode: Mode) {
665            *self.mode.lock() = mode;
666        }
667        fn set_delay(&self, delay: Duration) {
668            *self.delay.lock() = delay;
669        }
670    }
671
672    impl InternalAuthenticator for CountingAuth {
673        async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
674            self.calls.fetch_add(1, Ordering::SeqCst);
675            let delay = *self.delay.lock();
676            if !delay.is_zero() {
677                tokio::time::sleep(delay).await;
678            }
679            match *self.mode.lock() {
680                Mode::Succeed => Ok(PlatformIdentity::Shared {
681                    name: token.to_owned(),
682                }),
683                Mode::Unavailable => Err(InternalAuthNError::Unavailable),
684                Mode::Invalid => Err(InternalAuthNError::InvalidToken),
685            }
686        }
687    }
688
689    #[tokio::test]
690    async fn a_very_long_token_is_cached_like_any_other() {
691        // It used to bypass the cache, because the token *was* the key and a
692        // header-sized one cost that much memory per entry. The key is a digest
693        // now, so length no longer decides anything.
694        let cached =
695            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
696        let huge = "x".repeat(64 * 1024);
697
698        cached.authenticate(&huge).await.unwrap();
699        cached.authenticate(&huge).await.unwrap();
700
701        assert_eq!(cached.cache.lock().len(), 1);
702        assert_eq!(
703            cached.inner.calls(),
704            1,
705            "the second call must be served from cache"
706        );
707    }
708
709    #[test]
710    fn the_cache_key_is_a_fixed_size_digest_of_the_token() {
711        // The property the memory bound rests on: two tokens of wildly
712        // different length produce keys of the same size, and distinct tokens
713        // produce distinct keys.
714        let short = TokenKey::of("t");
715        let long = TokenKey::of(&"x".repeat(64 * 1024));
716
717        assert_eq!(short.0.len(), long.0.len());
718        assert_ne!(short, long);
719        assert_eq!(
720            short,
721            TokenKey::of("t"),
722            "the same token must map to itself"
723        );
724
725        // And it does not render the credential.
726        assert!(!format!("{:?}", TokenKey::of("super-secret")).contains("super-secret"));
727    }
728
729    #[test]
730    fn the_expiry_index_tracks_the_map_through_overwrites_and_removals() {
731        // The map and its index are only useful if they agree; an entry left in
732        // the index after its map entry is gone would evict the wrong token.
733        let mut cache = ExpiringCache::new();
734        let now = Instant::now();
735
736        cache.insert(
737            TokenKey::of("a"),
738            valid_entry("a", now + Duration::from_secs(30)),
739        );
740        cache.insert(
741            TokenKey::of("b"),
742            valid_entry("b", now + Duration::from_secs(10)),
743        );
744        // Overwrite `a` with a *sooner* expiry: the stale index entry must go,
745        // or `a` would look like it expires at the original, later instant.
746        cache.insert(
747            TokenKey::of("a"),
748            valid_entry("a", now + Duration::from_secs(5)),
749        );
750        assert_eq!(cache.len(), 2);
751        assert_eq!(
752            cache.by_expiry.len(),
753            2,
754            "index must not keep a stale entry"
755        );
756
757        cache.evict_soonest();
758        assert!(
759            !cache.contains_key(&TokenKey::of("a")),
760            "`a` now expires soonest"
761        );
762        assert!(cache.contains_key(&TokenKey::of("b")));
763        assert_eq!(cache.by_expiry.len(), 1);
764
765        cache.remove(&TokenKey::of("b"));
766        assert_eq!(cache.len(), 0);
767        assert_eq!(cache.by_expiry.len(), 0);
768    }
769
770    #[test]
771    fn sweeping_removes_exactly_the_expired() {
772        let mut cache = ExpiringCache::new();
773        let now = Instant::now();
774
775        cache.insert(
776            TokenKey::of("expired"),
777            valid_entry("expired", now.checked_sub(Duration::from_secs(1)).unwrap()),
778        );
779        cache.insert(
780            TokenKey::of("live"),
781            valid_entry("live", now + Duration::from_mins(1)),
782        );
783
784        cache.sweep_expired(now);
785
786        assert!(!cache.contains_key(&TokenKey::of("expired")));
787        assert!(cache.contains_key(&TokenKey::of("live")));
788        assert_eq!(
789            cache.by_expiry.len(),
790            1,
791            "the index must shrink with the map"
792        );
793    }
794
795    #[tokio::test(start_paused = true)]
796    async fn with_authentication_timeout_overrides_the_default() {
797        // A backend slower than the *shortened* deadline must still time out,
798        // even though it would comfortably fit inside the default 10s.
799        let short = Duration::from_millis(50);
800        let cached = CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1))
801            .unwrap()
802            .with_authentication_timeout(short);
803        cached.inner.set_delay(short * 2);
804
805        let err = cached
806            .authenticate("tok")
807            .await
808            .expect_err("a backend slower than the overridden deadline must not resolve");
809        assert!(matches!(err, InternalAuthNError::Unavailable));
810    }
811
812    #[tokio::test(start_paused = true)]
813    async fn a_hung_backend_times_out_rather_than_parking_every_caller() {
814        let cached =
815            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
816        // Longer than the deadline, i.e. a backend that never answers.
817        cached.inner.set_delay(DEFAULT_AUTHENTICATION_TIMEOUT * 2);
818
819        let err = cached
820            .authenticate("tok")
821            .await
822            .expect_err("a backend that outlasts the deadline must not resolve");
823        assert!(
824            matches!(err, InternalAuthNError::Unavailable),
825            "a timeout is a backend availability problem, not a verdict on the credential"
826        );
827    }
828
829    #[tokio::test(start_paused = true)]
830    async fn concurrent_callers_share_one_deadline_rather_than_queueing() {
831        // The regression this guards: the deadline used to be taken *after* the
832        // per-token lock, so three callers presenting the same token against a
833        // hung backend each started their own timer on reaching the front of
834        // the queue and finished at roughly 1x, 2x and 3x the deadline. All
835        // three must now fail within one deadline of their own arrival.
836        let cached = Arc::new(
837            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap(),
838        );
839        cached.inner.set_delay(DEFAULT_AUTHENTICATION_TIMEOUT * 10);
840
841        // `tokio::time::Instant`, not `std::time::Instant`: under a paused
842        // clock, tokio fast-forwards to the next timer with no real-time delay,
843        // so `std::time::Instant::elapsed()` would read near-zero regardless of
844        // how much virtual time passed -- the assertion below would pass even
845        // with the old queueing bug.
846        let started = tokio::time::Instant::now();
847        let mut callers = Vec::new();
848        for _ in 0..3 {
849            let cached = Arc::clone(&cached);
850            callers.push(tokio::spawn(async move {
851                let outcome = cached.authenticate("same-token").await;
852                (outcome, started.elapsed())
853            }));
854        }
855
856        for caller in callers {
857            let (outcome, elapsed) = caller.await.expect("task must not panic");
858            assert!(
859                matches!(outcome, Err(InternalAuthNError::Unavailable)),
860                "a hung backend must surface as Unavailable, got {outcome:?}"
861            );
862            assert!(
863                elapsed < DEFAULT_AUTHENTICATION_TIMEOUT * 2,
864                "caller waited {elapsed:?}, i.e. it queued behind another \
865                 caller's deadline instead of holding its own"
866            );
867        }
868    }
869
870    #[tokio::test(start_paused = true)]
871    async fn a_very_long_token_obeys_the_deadline_too() {
872        // Length no longer changes the path, but the deadline must still cover
873        // a long credential like any other.
874        let cached =
875            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
876        cached.inner.set_delay(DEFAULT_AUTHENTICATION_TIMEOUT * 2);
877
878        let huge = "x".repeat(64 * 1024);
879        let err = cached
880            .authenticate(&huge)
881            .await
882            .expect_err("a long token must be bounded as well");
883        assert!(matches!(err, InternalAuthNError::Unavailable));
884    }
885
886    #[tokio::test(start_paused = true)]
887    async fn a_timed_out_validation_is_not_cached() {
888        let cached =
889            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
890        cached.inner.set_delay(DEFAULT_AUTHENTICATION_TIMEOUT * 2);
891        drop(cached.authenticate("tok").await);
892
893        // The backend recovers; the next call must reach it rather than serve a
894        // cached failure, or one slow call would deny the token for a full TTL.
895        cached.inner.set_delay(Duration::ZERO);
896        let identity = cached
897            .authenticate("tok")
898            .await
899            .expect("a recovered backend must be consulted again");
900        assert_eq!(identity.peer_name(), "tok");
901        assert_eq!(
902            cached.inner.calls(),
903            2,
904            "the timed-out attempt must not have been cached"
905        );
906    }
907
908    #[tokio::test]
909    async fn second_call_within_ttl_hits_cache() {
910        let cached =
911            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
912
913        let a = cached.authenticate("tok").await.unwrap();
914        let b = cached.authenticate("tok").await.unwrap();
915        assert_eq!(a, b);
916        assert_eq!(
917            cached.inner.calls(),
918            1,
919            "second call must be served from cache"
920        );
921        assert_eq!(
922            a.peer_name(),
923            "tok",
924            "cached identity must match the token it was issued for"
925        );
926    }
927
928    #[tokio::test]
929    async fn distinct_tokens_are_cached_independently() {
930        let cached =
931            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
932
933        let a = cached.authenticate("a").await.unwrap();
934        let b = cached.authenticate("b").await.unwrap();
935        let a2 = cached.authenticate("a").await.unwrap();
936        assert_eq!(
937            cached.inner.calls(),
938            2,
939            "each distinct token validated once"
940        );
941        assert_eq!(a.peer_name(), "a");
942        assert_eq!(b.peer_name(), "b");
943        assert_eq!(
944            a2.peer_name(),
945            "a",
946            "cache must not confuse token identities"
947        );
948    }
949
950    #[tokio::test]
951    async fn entry_expires_after_ttl() {
952        // Deterministic via `authenticate_at`, not a real sleep: this crate's
953        // cache samples `Instant::now()` internally, which `tokio::time::pause`
954        // cannot virtualize, so a real-time sleep would be both wall-clock
955        // dependent and slow.
956        let cached =
957            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_millis(20))
958                .unwrap();
959        let t0 = Instant::now();
960
961        cached.authenticate_at("tok", t0).await.unwrap();
962        assert_eq!(cached.inner.calls(), 1);
963
964        cached
965            .authenticate_at("tok", t0 + Duration::from_millis(21))
966            .await
967            .unwrap();
968        assert_eq!(
969            cached.inner.calls(),
970            2,
971            "expired entry must be re-validated"
972        );
973    }
974
975    #[tokio::test]
976    async fn unavailable_errors_are_not_cached() {
977        let cached =
978            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
979        cached.inner.set_mode(Mode::Unavailable);
980
981        assert!(cached.authenticate("tok").await.is_err());
982        assert!(cached.authenticate("tok").await.is_err());
983        assert_eq!(
984            cached.inner.calls(),
985            2,
986            "backend outages must not be cached"
987        );
988
989        // Once the backend recovers, the next call succeeds and is then cached.
990        cached.inner.set_mode(Mode::Succeed);
991        cached.authenticate("tok").await.unwrap();
992        cached.authenticate("tok").await.unwrap();
993        assert_eq!(
994            cached.inner.calls(),
995            3,
996            "recovery validated once, then cached"
997        );
998    }
999
1000    #[tokio::test]
1001    async fn invalid_token_rejections_are_briefly_negative_cached() {
1002        let cached =
1003            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
1004        cached.inner.set_mode(Mode::Invalid);
1005        let t0 = Instant::now();
1006
1007        let err = cached.authenticate("bad").await.unwrap_err();
1008        assert!(matches!(err, InternalAuthNError::InvalidToken));
1009        // A second rejection within the negative-cache window is served
1010        // without a second backend call.
1011        let err = cached.authenticate("bad").await.unwrap_err();
1012        assert!(matches!(err, InternalAuthNError::InvalidToken));
1013        assert_eq!(
1014            cached.inner.calls(),
1015            1,
1016            "a cached rejection must not re-hit the backend"
1017        );
1018
1019        // Step past the negative-cache window with the injected instant rather
1020        // than a real sleep: this cache keys off `std::time::Instant`, which
1021        // `tokio::time::pause` cannot virtualize, so a sleep here would burn a
1022        // real second and still drift on a loaded runner.
1023        cached.inner.set_mode(Mode::Succeed);
1024        let identity = cached
1025            .authenticate_at("bad", t0 + NEGATIVE_CACHE_TTL + Duration::from_millis(1))
1026            .await
1027            .unwrap();
1028        assert_eq!(
1029            identity.peer_name(),
1030            "bad",
1031            "the token validates once the backend accepts it"
1032        );
1033        assert_eq!(cached.inner.calls(), 2);
1034    }
1035
1036    #[test]
1037    fn new_rejects_zero_and_over_max_ttl() {
1038        assert!(CachingInternalAuthenticator::new(CountingAuth::new(), Duration::ZERO).is_err());
1039        assert!(
1040            CachingInternalAuthenticator::new(
1041                CountingAuth::new(),
1042                MAX_TOKEN_REVIEW_CACHE_TTL + Duration::from_secs(1)
1043            )
1044            .is_err()
1045        );
1046        assert!(
1047            CachingInternalAuthenticator::new(CountingAuth::new(), MAX_TOKEN_REVIEW_CACHE_TTL)
1048                .is_ok()
1049        );
1050    }
1051
1052    fn valid_entry(name: &str, expires_at: Instant) -> CacheEntry {
1053        CacheEntry::Valid {
1054            identity: PlatformIdentity::Shared {
1055                name: name.to_owned(),
1056            },
1057            expires_at,
1058        }
1059    }
1060
1061    #[test]
1062    fn capacity_bound_evicts_soonest_to_expire_when_full() {
1063        let cached =
1064            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
1065        let now = Instant::now();
1066
1067        for i in 0..MAX_CACHE_ENTRIES {
1068            let name = format!("tok-{i}");
1069            // Ascending expiry: `tok-0` expires soonest.
1070            let expires_at = now + Duration::from_mins(1) + Duration::from_micros(i as u64);
1071            cached.insert(TokenKey::of(&name), valid_entry(&name, expires_at), now);
1072        }
1073        assert_eq!(cached.cache.lock().len(), MAX_CACHE_ENTRIES);
1074
1075        cached.insert(
1076            TokenKey::of("overflow"),
1077            valid_entry("overflow", now + Duration::from_mins(2)),
1078            now,
1079        );
1080
1081        let cache = cached.cache.lock();
1082        assert_eq!(
1083            cache.len(),
1084            MAX_CACHE_ENTRIES,
1085            "cache must never grow past MAX_CACHE_ENTRIES"
1086        );
1087        assert!(
1088            cache.contains_key(&TokenKey::of("overflow")),
1089            "the newly inserted token must be present"
1090        );
1091        assert!(
1092            !cache.contains_key(&TokenKey::of("tok-0")),
1093            "the soonest-to-expire entry must be evicted to make room"
1094        );
1095    }
1096
1097    #[test]
1098    fn full_cache_reclaims_expired_before_scanning_for_a_victim() {
1099        let cached =
1100            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap();
1101        let now = Instant::now();
1102
1103        // Fill one short of capacity with valid, far-future entries: the
1104        // soonest-to-expire scan would pick one of these if an expired entry
1105        // were not reclaimed first.
1106        for i in 1..MAX_CACHE_ENTRIES {
1107            let name = format!("tok-{i}");
1108            let expires_at = now + Duration::from_mins(5) + Duration::from_micros(i as u64);
1109            cached.insert(TokenKey::of(&name), valid_entry(&name, expires_at), now);
1110        }
1111        // Insert the already-expired entry last so a periodic sweep during the
1112        // fill loop above cannot reclaim it before the capacity path runs.
1113        cached.insert(
1114            TokenKey::of("expired"),
1115            valid_entry("expired", now.checked_sub(Duration::from_secs(1)).unwrap()),
1116            now,
1117        );
1118        assert_eq!(cached.cache.lock().len(), MAX_CACHE_ENTRIES);
1119
1120        cached.insert(
1121            TokenKey::of("overflow"),
1122            valid_entry("overflow", now + Duration::from_mins(10)),
1123            now,
1124        );
1125
1126        let cache = cached.cache.lock();
1127        assert_eq!(cache.len(), MAX_CACHE_ENTRIES);
1128        assert!(cache.contains_key(&TokenKey::of("overflow")));
1129        assert!(
1130            !cache.contains_key(&TokenKey::of("expired")),
1131            "the expired entry must be reclaimed by the sweep, making room"
1132        );
1133        assert!(
1134            cache.contains_key(&TokenKey::of("tok-1")),
1135            "a still-valid entry must not be evicted while an expired one exists"
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn concurrent_misses_for_the_same_token_single_flight() {
1141        let cached = Arc::new(
1142            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap(),
1143        );
1144
1145        let mut handles = Vec::new();
1146        for _ in 0..8 {
1147            let cached = Arc::clone(&cached);
1148            handles.push(tokio::spawn(
1149                async move { cached.authenticate("burst").await },
1150            ));
1151        }
1152        for handle in handles {
1153            handle.await.unwrap().unwrap();
1154        }
1155
1156        assert_eq!(
1157            cached.inner.calls(),
1158            1,
1159            "a burst of concurrent misses for the same token must collapse to one backend call"
1160        );
1161    }
1162
1163    #[tokio::test]
1164    async fn aborting_an_inflight_call_still_releases_the_single_flight_slot() {
1165        let auth = CountingAuth::new();
1166        auth.set_delay(Duration::from_millis(200));
1167        let cached =
1168            Arc::new(CachingInternalAuthenticator::new(auth, Duration::from_mins(1)).unwrap());
1169
1170        let handle = {
1171            let cached = Arc::clone(&cached);
1172            tokio::spawn(async move { cached.authenticate("burst").await })
1173        };
1174        // Give the task time to take the single-flight lock and start
1175        // blocking on the (delayed) backend call before aborting it.
1176        tokio::time::sleep(Duration::from_millis(20)).await;
1177        handle.abort();
1178        let result = handle.await;
1179        assert!(
1180            result.unwrap_err().is_cancelled(),
1181            "the task must actually have been aborted mid-flight for this test to be meaningful"
1182        );
1183
1184        assert!(
1185            cached.inflight.lock().is_empty(),
1186            "aborting an in-flight call must not leak its single-flight slot"
1187        );
1188
1189        // The slot must also be fully usable afterwards: a fresh call for the
1190        // same token must not deadlock on a lock nobody will ever release.
1191        let identity = tokio::time::timeout(Duration::from_secs(1), cached.authenticate("burst"))
1192            .await
1193            .expect("post-abort call must not hang")
1194            .unwrap();
1195        assert_eq!(identity.peer_name(), "burst");
1196    }
1197
1198    #[test]
1199    fn clamped_expiry_never_extends_trust_past_an_expired_jwt() {
1200        let now = Instant::now();
1201        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":1}"#);
1202        let expired_token = format!("h.{payload}.s");
1203        assert_eq!(
1204            clamped_expiry(&expired_token, now, Duration::from_mins(1)),
1205            now,
1206            "a JWT already expired per its own claim must not be trusted at all"
1207        );
1208    }
1209
1210    #[test]
1211    fn clamped_expiry_uses_jwt_remaining_life_when_shorter_than_ttl() {
1212        let now = Instant::now();
1213        let exp = (SystemTime::now() + Duration::from_secs(2))
1214            .duration_since(UNIX_EPOCH)
1215            .unwrap()
1216            .as_secs();
1217        let payload =
1218            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
1219        let token = format!("h.{payload}.s");
1220        let expiry = clamped_expiry(&token, now, Duration::from_mins(5));
1221
1222        // Bounded from both sides. An upper bound alone holds for any value
1223        // below the ttl, `now` included, so a clamp that collapsed to zero --
1224        // disabling positive caching entirely -- would still pass.
1225        //
1226        // The lower bound is `now`, not `now + 1s`: `exp` is whole seconds, so
1227        // truncation leaves anywhere from just over 1s to a full 2s of life,
1228        // and a tighter bound would fail on scheduling delay alone.
1229        assert!(
1230            expiry > now,
1231            "a token with life left must produce a non-zero cache lifetime"
1232        );
1233        assert!(
1234            expiry <= now + Duration::from_secs(2),
1235            "a JWT with less remaining life than the configured ttl must clamp to the JWT's expiry"
1236        );
1237    }
1238
1239    #[tokio::test]
1240    async fn second_waiter_reuses_result_populated_while_it_waited() {
1241        let auth = CountingAuth::new();
1242        auth.set_delay(Duration::from_millis(50));
1243        let cached =
1244            Arc::new(CachingInternalAuthenticator::new(auth, Duration::from_mins(1)).unwrap());
1245
1246        let first = {
1247            let cached = Arc::clone(&cached);
1248            tokio::spawn(async move { cached.authenticate("burst").await })
1249        };
1250        // Give the first call time to take the single-flight lock and start
1251        // its (delayed) backend call, so this call genuinely blocks on the
1252        // lock instead of racing it.
1253        tokio::time::sleep(Duration::from_millis(10)).await;
1254        let second = cached.authenticate("burst").await.unwrap();
1255        let first = first.await.unwrap().unwrap();
1256
1257        assert_eq!(first, second);
1258        assert_eq!(
1259            cached.inner.calls(),
1260            1,
1261            "a waiter that blocked on the single-flight lock must reuse the result the winner stored"
1262        );
1263    }
1264
1265    #[tokio::test]
1266    async fn cancelling_a_waiter_still_releases_its_inflight_registration() {
1267        // Regression: `ReleaseTokenLockOnDrop` used to be constructed *after*
1268        // `lock.lock().await`. For 32 distinct tokens: A takes the lock and
1269        // enters a very slow backend call; B queues behind A on the same
1270        // token. Cancelling A first is fine -- its cleanup sees B still holds
1271        // a clone and correctly leaves the entry. But cancelling B *before it
1272        // resumes* used to leak: B had no guard yet, so nothing ever asked its
1273        // entry to remove itself, and it outlived every reference to it.
1274        let cached = Arc::new(
1275            CachingInternalAuthenticator::new(CountingAuth::new(), Duration::from_mins(1)).unwrap(),
1276        );
1277        cached.inner.set_delay(Duration::from_secs(30));
1278
1279        for i in 0..32 {
1280            let token = format!("tok-{i}");
1281
1282            let a = tokio::spawn({
1283                let cached = Arc::clone(&cached);
1284                let token = token.clone();
1285                async move { cached.authenticate(&token).await }
1286            });
1287            // Let A win the per-token lock and enter the (slow) backend call.
1288            tokio::time::sleep(Duration::from_millis(5)).await;
1289
1290            let b = tokio::spawn({
1291                let cached = Arc::clone(&cached);
1292                let token = token.clone();
1293                async move { cached.authenticate(&token).await }
1294            });
1295            // Let B register for the same token and start waiting on the lock
1296            // A already holds.
1297            tokio::time::sleep(Duration::from_millis(5)).await;
1298
1299            // Abort both back to back, with no `.await` between them: once a
1300            // task is marked aborted, tokio drops its future from whatever
1301            // state it was suspended in rather than polling it further, so B
1302            // is captured genuinely still waiting for the lock -- not given a
1303            // chance to run once A's release wakes it.
1304            a.abort();
1305            b.abort();
1306            drop(a.await);
1307            drop(b.await);
1308        }
1309
1310        assert_eq!(
1311            cached.inflight.lock().len(),
1312            0,
1313            "every cancelled waiter -- holding the lock or still queued for it -- \
1314             must release its `inflight` registration"
1315        );
1316    }
1317
1318    #[tokio::test]
1319    async fn second_waiter_reuses_rejection_populated_while_it_waited() {
1320        let auth = CountingAuth::new();
1321        auth.set_delay(Duration::from_millis(50));
1322        auth.set_mode(Mode::Invalid);
1323        let cached =
1324            Arc::new(CachingInternalAuthenticator::new(auth, Duration::from_mins(1)).unwrap());
1325
1326        let first = {
1327            let cached = Arc::clone(&cached);
1328            tokio::spawn(async move { cached.authenticate("burst").await })
1329        };
1330        tokio::time::sleep(Duration::from_millis(10)).await;
1331        let second = cached.authenticate("burst").await;
1332        let first = first.await.unwrap();
1333
1334        assert!(matches!(first, Err(InternalAuthNError::InvalidToken)));
1335        assert!(matches!(second, Err(InternalAuthNError::InvalidToken)));
1336        assert_eq!(
1337            cached.inner.calls(),
1338            1,
1339            "a waiter that blocked on the single-flight lock must reuse the cached rejection"
1340        );
1341    }
1342
1343    #[tokio::test]
1344    async fn lock_waiter_revalidates_an_entry_that_expired_during_the_wait() {
1345        let auth = CountingAuth::new();
1346        // Widen the single-flight window so the second caller genuinely blocks
1347        // on the per-token lock across the winner's backend round-trip.
1348        auth.set_delay(Duration::from_millis(50));
1349        let cached =
1350            Arc::new(CachingInternalAuthenticator::new(auth, Duration::from_mins(1)).unwrap());
1351
1352        // An already-expired JWT: the backend accepts it, but `clamped_expiry`
1353        // stores it with `expires_at == stored_at` — expired the instant it
1354        // lands. A waiter that re-checked with its pre-wait `now` (sampled
1355        // before `stored_at`) would wrongly serve it; the refreshed cutoff must
1356        // treat it as a miss and re-validate.
1357        let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":1}"#);
1358        let token = format!("h.{payload}.s");
1359
1360        let first = {
1361            let cached = Arc::clone(&cached);
1362            let token = token.clone();
1363            tokio::spawn(async move { cached.authenticate(&token).await })
1364        };
1365        // Let the winner take the lock and start its (delayed) backend call so
1366        // this caller blocks on the lock rather than racing it.
1367        tokio::time::sleep(Duration::from_millis(10)).await;
1368        cached.authenticate(&token).await.unwrap();
1369        first.await.unwrap().unwrap();
1370
1371        assert_eq!(
1372            cached.inner.calls(),
1373            2,
1374            "a waiter must re-validate an entry that expired during its wait, not serve it stale"
1375        );
1376    }
1377
1378    /// Build `header.payload.signature` around a base64url payload.
1379    fn jwt_with_payload(payload: &[u8]) -> String {
1380        let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload);
1381        format!("h.{encoded}.s")
1382    }
1383
1384    #[test]
1385    fn jwt_exp_claim_extracts_and_ignores_non_jwt() {
1386        let token = jwt_with_payload(br#"{"exp":123}"#);
1387        assert!(matches!(jwt_exp_claim(&token), ExpClaim::Expires(123)));
1388
1389        assert!(matches!(jwt_exp_claim("not-a-jwt"), ExpClaim::NotJwt));
1390        assert!(matches!(
1391            jwt_exp_claim("shared-secret-token"),
1392            ExpClaim::NotJwt
1393        ));
1394    }
1395
1396    #[test]
1397    fn jwt_exp_claim_separates_unreadable_from_absent() {
1398        // The distinction that matters: a credential with no expiry to honour
1399        // is cacheable for the full TTL, one that declares an unreadable expiry
1400        // is not.
1401        assert!(matches!(
1402            jwt_exp_claim(&jwt_with_payload(br"{}")),
1403            ExpClaim::Unreadable
1404        ));
1405        assert!(matches!(
1406            jwt_exp_claim(&jwt_with_payload(br#"{"exp":"soon"}"#)),
1407            ExpClaim::Unreadable
1408        ));
1409        assert!(matches!(
1410            jwt_exp_claim(&jwt_with_payload(br#"{"exp":1.5}"#)),
1411            ExpClaim::Unreadable
1412        ));
1413        assert!(matches!(
1414            jwt_exp_claim(&jwt_with_payload(br"not json")),
1415            ExpClaim::Unreadable
1416        ));
1417        assert!(matches!(
1418            jwt_exp_claim("h.!!!not-base64!!!.s"),
1419            ExpClaim::Unreadable
1420        ));
1421    }
1422
1423    #[test]
1424    fn unreadable_exp_is_not_cached_for_the_full_ttl() {
1425        let now = Instant::now();
1426        let ttl = Duration::from_mins(5);
1427
1428        // No expiry declared at all: the TTL applies.
1429        assert_eq!(
1430            clamped_expiry("shared-secret-token", now, ttl),
1431            now + ttl,
1432            "an opaque credential has no expiry to clamp against"
1433        );
1434
1435        // An expiry we cannot read: expire immediately rather than trust it for
1436        // five minutes.
1437        let unreadable = jwt_with_payload(br#"{"exp":"soon"}"#);
1438        assert_eq!(
1439            clamped_expiry(&unreadable, now, ttl),
1440            now,
1441            "a declared-but-unreadable expiry must not be cached for the full TTL"
1442        );
1443    }
1444
1445    #[test]
1446    fn absurd_exp_claims_do_not_panic() {
1447        let now = Instant::now();
1448        let ttl = Duration::from_secs(30);
1449
1450        // `SystemTime + Duration` and `Instant + Duration` both panic on
1451        // overflow, and `exp` is attacker-influenced.
1452        for payload in [
1453            format!(r#"{{"exp":{}}}"#, u64::MAX),
1454            format!(r#"{{"exp":{}}}"#, i64::MAX),
1455            r#"{"exp":0}"#.to_owned(),
1456        ] {
1457            let token = jwt_with_payload(payload.as_bytes());
1458            let expiry = clamped_expiry(&token, now, ttl);
1459            assert!(
1460                expiry <= now + ttl,
1461                "the clamp must never extend trust beyond the configured TTL"
1462            );
1463        }
1464    }
1465}