Exchange tokenCache hardening: digest key, single-flight, subject-exp-capped TTL, zeroized value #61
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lab/authz-bridge#61
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The Exchange flow's per-replica
tokenCache(internal/serve/exchange_cache.go) ismodel-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)
Digest the cache key — do not retain the raw credential.
Our
tokenCacheKeystores the rawsubjectTokenstring (a bearer credential) as thekey. 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.
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'sentry_async+ a guard so concurrentsame-key requests share a single fetch.
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 canoutlive the credential it was derived from. agentgateway sets
expires_at = min(now + expires_in, subject_token.exp)(best-effortexpfrom aJWT-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.
Zeroize the cached value.
We store the exchanged token as a plain
String. agentgateway wraps it insecrecy::SecretString(zeroized on drop/eviction). Go equivalent: store the token ina
[]bytethat is explicitly zeroed on eviction/drop (or a small zeroizing stringwrapper), rather than a heap
stringthat lingers until GC.Nice-to-have (lower priority)
with
0disabling the cache. Ours is fixed at 1024 / 60s.CACHE_SAFETY_MARGIN(15s) refuses to servean 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-expcap),is_fresh(safety margin),SecretStringvalue.Scope note
tokenCacheonly.(caching a rotated, at-most-once refresh token would invert Kanidm's reuse detection).
Verification
cache.rstest cases):exp(a short-lived JWT subject expiresthe entry early);
digest);
GOTOOLCHAIN=go1.26.0 make lintandGOTOOLCHAIN=go1.26.0 make test.