Exchange tokenCache hardening: digest key, single-flight, subject-exp-capped TTL, zeroized value #61

Open
opened 2026-09-20 19:05:09 +00:00 by ginjiruu · 0 comments
Owner

Context

The Exchange flow's per-replica tokenCache (internal/serve/exchange_cache.go) is
model-consistent — it maps a live, non-rotated credential (the service-account API
token) to a derived access token, so it does not trip Kanidm's at-most-once/reuse
guarantee (see #60's out-of-scope note for why the OIDC refresh must NOT be cached).

But agentgateway's equivalent cache (crates/agentgateway/src/http/auth/oauth/cache.rs,
Apache-2.0) has several security/robustness properties ours lacks. This issue tracks
adopting them. Independent of #60 — it hardens the existing Exchange cache and does
not add any cache to the OIDC refresh path.

Improvements (adopt from agentgateway)

  1. Digest the cache key — do not retain the raw credential.
    Our tokenCacheKey stores the raw subjectToken string (a bearer credential) as the
    key. agentgateway keys by a SHA-256 digest of the exchange inputs
    (subject_token, subject_token_type, actor, extra params), explicitly "so the raw
    bearer credential is never retained as a cache key."
    The digest is length-prefixed
    per field to stay collision-safe.

  2. Single-flight on a cold key (no thundering herd).
    Our LRU has no de-duplication: N concurrent misses for the same key → N token-endpoint
    calls. That is exactly the #53 cold-exchange case (a fresh replica's first exchange is
    ~330ms). agentgateway uses quick_cache's entry_async + a guard so concurrent
    same-key requests share a single fetch.

  3. Cap the entry TTL by the subject token's own exp.
    Our TTL is just the token's expires_in (or the 60s default), so a cached entry can
    outlive the credential it was derived from. agentgateway sets
    expires_at = min(now + expires_in, subject_token.exp) (best-effort exp from a
    JWT-shaped subject token; opaque tokens fall back to the endpoint TTL). It also
    does not cache a result when the subject token is already expired or within the
    safety margin of expiry.

  4. Zeroize the cached value.
    We store the exchanged token as a plain String. agentgateway wraps it in
    secrecy::SecretString (zeroized on drop/eviction). Go equivalent: store the token in
    a []byte that is explicitly zeroed on eviction/drop (or a small zeroizing string
    wrapper), rather than a heap string that lingers until GC.

Nice-to-have (lower priority)

  1. Configurable capacity / TTL. agentgateway defaults to 8192 entries and a 300s TTL,
    with 0 disabling the cache. Ours is fixed at 1024 / 60s.
  2. Safety margin on serve. agentgateway's CACHE_SAFETY_MARGIN (15s) refuses to serve
    an entry that is within 15s of expiry, so a request never receives a token that is
    about to die mid-flight.

Prior art

agentgateway, crates/agentgateway/src/http/auth/oauth/cache.rs (Apache-2.0):
https://github.com/agentgateway/agentgatewayInMemoryTokenCache, TokenCacheKey
(digest), cache_expiry (subject-exp cap), is_fresh (safety margin),
SecretString value.

Scope note

  • Hardens the Exchange tokenCache only.
  • Does not add a cache to the OIDC refresh path — that stays out of scope per #60
    (caching a rotated, at-most-once refresh token would invert Kanidm's reuse detection).

Verification

  • Unit tests (mirror agentgateway's cache.rs test cases):
    • same request → 1 token-endpoint call, second is a cache hit;
    • different subject token / scopes / audience → cache miss;
    • concurrent same-key misses → 1 token-endpoint call (single-flight);
    • entry TTL is capped by the subject token's exp (a short-lived JWT subject expires
      the entry early);
    • expired / near-expiry subject token → not cached;
    • the raw subject token is not retained as a key (assert the key is a fixed-size
      digest);
    • evicted/dropped values are zeroized.
  • GOTOOLCHAIN=go1.26.0 make lint and GOTOOLCHAIN=go1.26.0 make test.
## Context The Exchange flow's per-replica `tokenCache` (`internal/serve/exchange_cache.go`) is model-consistent — it maps a **live, non-rotated** credential (the service-account API token) to a derived access token, so it does not trip Kanidm's at-most-once/reuse guarantee (see #60's out-of-scope note for why the *OIDC refresh* must NOT be cached). But agentgateway's equivalent cache (`crates/agentgateway/src/http/auth/oauth/cache.rs`, Apache-2.0) has several security/robustness properties ours lacks. This issue tracks adopting them. Independent of #60 — it hardens the *existing* Exchange cache and does not add any cache to the OIDC refresh path. ## Improvements (adopt from agentgateway) 1. **Digest the cache key — do not retain the raw credential.** Our `tokenCacheKey` stores the raw `subjectToken` string (a bearer credential) as the key. agentgateway keys by a **SHA-256 digest** of the exchange inputs (subject_token, subject_token_type, actor, extra params), explicitly *"so the raw bearer credential is never retained as a cache key."* The digest is length-prefixed per field to stay collision-safe. 2. **Single-flight on a cold key (no thundering herd).** Our LRU has no de-duplication: N concurrent misses for the same key → N token-endpoint calls. That is exactly the #53 cold-exchange case (a fresh replica's first exchange is ~330ms). agentgateway uses `quick_cache`'s `entry_async` + a guard so concurrent same-key requests share a single fetch. 3. **Cap the entry TTL by the subject token's own `exp`.** Our TTL is just the token's `expires_in` (or the 60s default), so a cached entry can outlive the credential it was derived from. agentgateway sets `expires_at = min(now + expires_in, subject_token.exp)` (best-effort `exp` from a JWT-shaped subject token; opaque tokens fall back to the endpoint TTL). It also **does not cache** a result when the subject token is already expired or within the safety margin of expiry. 4. **Zeroize the cached value.** We store the exchanged token as a plain `String`. agentgateway wraps it in `secrecy::SecretString` (zeroized on drop/eviction). Go equivalent: store the token in a `[]byte` that is explicitly zeroed on eviction/drop (or a small zeroizing string wrapper), rather than a heap `string` that lingers until GC. ## Nice-to-have (lower priority) 5. **Configurable capacity / TTL.** agentgateway defaults to 8192 entries and a 300s TTL, with `0` disabling the cache. Ours is fixed at 1024 / 60s. 6. **Safety margin on serve.** agentgateway's `CACHE_SAFETY_MARGIN` (15s) refuses to serve an entry that is within 15s of expiry, so a request never receives a token that is about to die mid-flight. ## Prior art agentgateway, `crates/agentgateway/src/http/auth/oauth/cache.rs` (Apache-2.0): https://github.com/agentgateway/agentgateway — `InMemoryTokenCache`, `TokenCacheKey` (digest), `cache_expiry` (subject-`exp` cap), `is_fresh` (safety margin), `SecretString` value. ## Scope note - Hardens the **Exchange** `tokenCache` only. - Does **not** add a cache to the OIDC refresh path — that stays out of scope per #60 (caching a rotated, at-most-once refresh token would invert Kanidm's reuse detection). ## Verification - Unit tests (mirror agentgateway's `cache.rs` test cases): - same request → 1 token-endpoint call, second is a cache hit; - different subject token / scopes / audience → cache miss; - **concurrent** same-key misses → 1 token-endpoint call (single-flight); - entry TTL is capped by the subject token's `exp` (a short-lived JWT subject expires the entry early); - expired / near-expiry subject token → not cached; - the raw subject token is **not** retained as a key (assert the key is a fixed-size digest); - evicted/dropped values are zeroized. - `GOTOOLCHAIN=go1.26.0 make lint` and `GOTOOLCHAIN=go1.26.0 make test`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lab/authz-bridge#61
No description provided.