fix(auth): refresh JWKS once on unknown kid during key rotation
Summary
Backend bearer-token validation returns NoMatchingKey immediately when a token kid is absent from the cached JWKS. The shared provider refreshes only on its one-hour background interval, so normal IdP signing-key rotation can make newly issued tokens fail across backend services for up to an hour.
craig-web already implements the correct behavioral shape for login: on JwksNotLoaded or NoMatchingKey, refresh and validate once more. The shared ClaimsExtractor paths used by APIs call validate_token directly and do not receive that recovery behavior.
Evidence
crates/craig-auth/src/jwks.rs:21-22sets a one-hour refresh interval.- Lines 315-325 implement periodic refresh only.
- Lines 349-357 return
NoMatchingKeywithout refreshing. crates/craig-auth/src/middleware.rs:48-51and 101-106 callvalidate_tokendirectly for JWS tokens.services/craig-web/src/auth_verify.rs:50-62already refreshes and retries once on exactly these recoverable variants.
Why it matters
Signing-key rotation is normal IdP operation. A stale cache should remain fail-closed without turning rotation into a fleet-wide authentication outage.
Recommended fix
Centralize refresh-on-miss in JwksProvider or the shared JWS extractor: refresh once for JwksNotLoaded/NoMatchingKey, then revalidate. Deduplicate concurrent refreshes with a single-flight lock and add a short cooldown/negative-key guard so arbitrary unknown kid tokens cannot force unbounded IdP traffic.
Acceptance criteria
- A token signed by a newly rotated legitimate key succeeds after one on-demand refresh.
- Invalid signatures, wrong issuer/audience/type/azp, expired tokens, and missing
kiddo not trigger permissive fallback. - Concurrent requests for one new
kidcause at most one JWKS fetch. - Repeated attacker-controlled unknown kids are refresh-rate-limited without weakening rejection.
- Refresh failure returns 401/503 according to the existing auth error contract and never accepts the token.
- All backend
ClaimsExtractormodes receive the behavior; duplicate craig-web-local logic is retired or delegates to shared code. - Rotation, refresh-failure, and refresh-storm tests are included.
References
- Source: 2026-07-09 repository review.
- Backlog reconciliation: not tracked. #962 only shares craig-web caches/tasks and explicitly requires no token-validation behavior change.