ADR-037: Signing-Key-Aware Service-Token Acquisition
On this page
Status
Accepted (2026-07-12); amended 2026-07-29 by #1212 (scale audit H5): the revalidation verdict is cached
for M (no per-call RSA verify / JWKS I/O inside the window), serve-path JWKS freshness is ensured by a
non-blocking single-flighted background fetch (callers never wait), and failed fetches are debounced
(30s) so a degraded IdP is probed at most once per window per process. The #1040 per-service
RefreshingToken workaround in canopy-reporting is retired. Worst-case key-deletion detection becomes
2·M + one fetch (was M + one fetch).
Realized by epic &70 (#1036 plan/ADR, #1037 JWKS hardening, #1038 source + bootstrap, #1039 portal, #1040 reporting, #1041 ELE scheduler, #1042 reporting error-swallow fix); see the plan. Relates to #610 (the seed-profile- brittleness facet historically bundled with this symptom, not closed by this work).
Amends
-
ADR-019 — the service acquires its own
client_credentialstoken from canopy-identity and caches it. ADR-019 (and the ADR-005 graceful- degradation posture) mitigated JWKS staleness on the receiver — a receiver force-refreshes on an unknownkid. This ADR extends that mitigation to the sender: the holder of a cached token now detects when its own token’s signing key has been deleted and re-mints, instead of serving a token that every receiver will reject. Operator ownership of credential (client-secret) rotation is unchanged.
Context
ServiceTokenSource::current() serves a cached token while expires_at > now() — a TTL-only check that
never revalidates the signature. A token’s true validity is TTL-valid AND signed by a key the issuer
still publishes.
The IdP (Keycloak / canopy-identity) rotates its OIDC token-signing keys and, after a grace period,
deletes the retired key from its JWKS. A token minted under the deleted key stays TTL-valid but is
signed by a kid the issuer no longer publishes, so every receiver’s JwksProvider::validate_token
rejects it (401). The live symptom is WIC / caps / medicaid / tanf POST /v1/determine →
canopy-rules-client → 500, lasting from the key deletion until the sender’s next proactive re-mint
(sources re-mint ~5 min before expiry; dev Keycloak TTL is 1800s ⇒ up to a ~25-min outage).
This concerns the IdP token-signing-key system — the JWKS the services fetch from canopy-identity to
validate inbound service tokens. It is distinct from canopy’s own determination-JWS verification
keys, which ADR-036 §6 retains persistently in
signing_key_history so any determination ever signed stays verifiable. ADR-036 fixed the receiver side
of canopy’s determination signatures; this ADR fixes the sender side of IdP service tokens. The two key
systems are not the same, and this ADR does not touch signing_key_history.
A naive kid-membership check is insufficient. A sender’s JWKS cache can hold a stale {old, new}
superset; after old is deleted, inbound tokens use new (a cache hit), so inbound validation never
force-refreshes, and a membership check reads old as still-present — it never detects the deletion.
Detection requires an authoritatively fresh JWKS.
Decision
1. Bounded-freshness full revalidation
A sender considers its cached token valid iff it would pass the receiver’s own validate_token
(signature, kid, alg, iss, exp/nbf, aud, typ) against a JWKS force-refreshed within a
max-age M (single-flighted). On a key/signature failure against a fresh JWKS, the sender re-mints and
revalidates the fresh candidate before serving it. This makes sender-validity ≡ receiver-validity: if the
sender serves a token, an in-sync receiver accepts it. Validity is the full validator, not kid-membership,
so no durable-kid contract is introduced. M = 60s, config-overridable.
Revalidation runs at token-acquisition time (current()), not as a 401-retry after a failed call — so
there is no ambiguous-401 problem (an application 401 for a bad passcode is not a token problem), no
request replay, no doubling of outbound sends, and no interaction with per-call deadlines.
Verdict caching (#1212). A passing verdict is itself stamped on the cached token (validated_at)
and honoured for M: inside the window current() serves with no RSA verify and no JWKS I/O — the
original per-call full validation was the H5 scale defect (a duplicate RSA verify on every outbound call,
fleet-wide). The staleness trade is explicit: worst-case detection of a deleted signing key is
2·M + one fetch, because the first post-window call both revalidates and triggers the JWKS refresh —
at most one stale re-stamp (against the pre-deletion key set) can occur before fresh keys land and the
next lapsed window fails closed. Still config-overridable via M, still orders of magnitude below the
pre-ADR ~25-minute re-mint window.
2. Opt-in; back-compat by construction
Revalidation is a revalidation: Option<Revalidation { provider, max_age }> set only by a new
with_self_validation(provider, max_age) builder on ServiceTokenSource. None ⇒ current() behaves
exactly as before. Only the two production constructors (canopy-api bootstrap and canopy-portal) opt in;
every new_for_tests fixture leaves it None. There is no bypass flag and no test churn — the legacy
path is the absence of a provider, not a feature toggle.
3. Fail-open vs fail-closed (stated honestly)
-
Cached token fails key/signature without a fresh JWKS to judge by — the refresh failed, is failure-debounced, or (since #1212) is still in flight in the background, or the key cache has never loaded ⇒ fail-open: serve the cached token and
warn!. Re-minting cannot be judged without fresh keys, and breaking all outbound calls on a transient blip is worse than serving a token that might still be valid; a later call adopts the fetch result and re-judges. -
Cached token fails key/signature against a fresh JWKS ⇒ fail-closed: the key is genuinely gone; re-mint and revalidate the fresh candidate; if that still fails by
ACQUIRE_DEADLINE, returnKeyRevoked { retry_after }rather than serve a token every receiver will reject. -
Cached token is merely expired ⇒ cold acquisition (today’s path). Any other validation failure (aud/iss/typ) ⇒
MalformedToken— a configuration bug, surfaced not masked. (A candidate that cannot be verified because the key cache never loaded classifies as couldn’t-verify — retried to the deadline, thenAcquisitionTimeoutwith cooldown — notMalformedToken; #1212.)
4. Concurrency correctness
JwksProvider refresh is hardened against two pre-existing races: a single-flight refresh_lock — held
across the upstream fetch — collapses concurrent refreshes to one fetch and serializes installs, so a slow
pre-deletion fetch cannot clobber a newer post-deletion keyset (no write-time compare-and-swap is needed). A
generation counter inside the keyset lock is the change-detector for the coalescing re-check: a waiter
whose pre-lock snapshot is stale but whose freshness bound is now satisfied adopts the just-installed keyset
instead of re-fetching. Token
minting is likewise single-flighted under a mint_lock with an explicit cooldown (5s → 60s backoff
jitter) so a known-dead caller neither serves the dead token nor hammers the token endpoint. The lock order
L_MINT ≺ L_REFRESH ≺ L_KEYS (and L_MINT ≺ L_CACHED) is acyclic; leaf data-locks are never held across
a network .await.
Serve-path freshness never blocks a caller (#1212). The original design had the serve path’s
ensure_fresh wait on refresh_lock — so with a stale cache and a degraded IdP, every outbound call
queued behind serial up-to-10s fetch attempts: a fleet-synchronized outbound convoy (scale audit H5).
Since #1212 the serve path uses ensure_fresh_background: the freshness check try-locks; the winner
spawns the fetch as a detached single-flighted task (the owned lock guard rides into it) and every
caller — winner included — returns immediately (Pending), proceeding on cached material. A failure
debounce (30s) makes a fetch failure short-circuit subsequent MaxAge attempts (blocking and
background alike, checked both before and under the lock) to Failed without a probe, so a down IdP is
probed at most once per window per process while everyone else fails open instantly. The acquisition
path deliberately keeps the blocking ensure_fresh: the mint loop must observe the fetch result
synchronously to distinguish revoked-against-fresh-keys from couldn’t-verify.
5. Bounded-freshness adoption rule
The design self-heals any caller that re-resolves current() within M of its sends. Callers that reuse a
single resolved token beyond M must resolve per send or per bounded batch (< M). With the #1212 verdict
cache, per-send resolution is a cache read (no crypto, no I/O inside the window), so the canopy-reporting
scoped clients resolve on every request — their per-service RefreshingToken reuse-window workaround
(#1040) is retired. The canopy-medicaid ELE scheduler resolves per bounded batch (#1219) and preserves
systemic abort — a token-acquisition failure aborts the tick rather than degrading into thousands of
per-row errors under a false Ok.
Detection latency + consequences
-
Worst-case detection latency after a key deletion is bounded by 2·M (default M = 60s) plus one JWKS fetch (#1212: the verdict window plus the JWKS age behind it), versus up to the full re-mint window (~25 min in dev) before this ADR.
-
The fast path is a cache read: inside the verdict window
current()performs no RSA verify and no JWKS I/O (#1212); outside it, the only network cost is a detached single-flighted background fetch that no caller waits on. -
New public API on canopy-auth:
RefreshOutcome(incl. the #1212Pendingvariant),JwksProvider::ensure_fresh/ensure_fresh_background/for_self_validation/validate_current,ServiceTokenSource::with_self_validation, and theServiceTokenError::KeyRevoked/MalformedToken/AcquisitionTimeoutvariants. Response shapes at service boundaries are unchanged. -
Manual recovery (
cargo xtask dev reload) remains valid but is no longer required for this failure mode; the runbook gains a sender-stale-token vs receiver-stale-JWKS decision tree.
Threat model
The self-heal keys entirely off the IdP’s published JWKS — no secret, no obscurity (Kerckhoffs). A sender
cannot be tricked into accepting a bad token: revalidation uses the same validate_token a receiver
uses, so the sender is strictly more conservative than before (it previously served on TTL alone). The
fail-open branch is the one place a possibly-stale token is served, and only when the JWKS is unreachable
— i.e. when re-minting cannot help and the alternative is a total outbound outage; it is warn!-logged
and rate-limited. A malicious JWKS endpoint is already in ADR-019’s trust boundary (the discovery document
is trusted); this ADR adds no new trust in it.
Alternatives considered
-
Reactive 401-retry (re-mint on a received 401, retry the call). Rejected: a 401 is an ambiguous signal (application-level auth failures also 401), it replays the request, it doubles outbound sends under a real outage, it breaks per-call deadlines, and it requires touching ~57 call sites. Proactive revalidation at the source fixes the root cause in one place.
-
Kid-membership check (is the cached token’s
kidin the current JWKS?). Rejected: defeated by the stale{old, new}cache (inbound traffic onnewnever forces a refresh, sooldlingers); it is a weaker signal than full validation and would require a new durable-kidcontract. -
Operational grace period only (lengthen the IdP key-deletion grace so the window never bites). Rejected as a fig leaf: it reduces the probability but does not self-heal, and it couples canopy correctness to an IdP operational parameter.
-
Whole-stack
dev reloadas the only recovery. Rejected as the primary mechanism: it is a manual bounce of the entire stack for a single service’s stale cache. It survives as documented manual recovery.