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-22 sets a one-hour refresh interval.
  • Lines 315-325 implement periodic refresh only.
  • Lines 349-357 return NoMatchingKey without refreshing.
  • crates/craig-auth/src/middleware.rs:48-51 and 101-106 call validate_token directly for JWS tokens.
  • services/craig-web/src/auth_verify.rs:50-62 already 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.

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 kid do not trigger permissive fallback.
  • Concurrent requests for one new kid cause 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 ClaimsExtractor modes 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.