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_credentials token 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 unknown kid. 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/determinecanopy-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. Nonecurrent() 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 JWKSfail-closed: the key is genuinely gone; re-mint and revalidate the fresh candidate; if that still fails by ACQUIRE_DEADLINE, return KeyRevoked { 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, then AcquisitionTimeout with cooldown — not MalformedToken; #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 #1212 Pending variant), JwksProvider::ensure_fresh / ensure_fresh_background / for_self_validation / validate_current, ServiceTokenSource::with_self_validation, and the ServiceTokenError::KeyRevoked / MalformedToken / AcquisitionTimeout variants. 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 kid in the current JWKS?). Rejected: defeated by the stale {old, new} cache (inbound traffic on new never forces a refresh, so old lingers); it is a weaker signal than full validation and would require a new durable-kid contract.

  • 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 reload as 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.

Edit this page · default