Skip to content

feat: JWKSValidator — JWT signature verification via JWKS (RFC 7517)#65

Draft
kalidke wants to merge 4 commits into
mainfrom
feature/jwks-validator
Draft

feat: JWKSValidator — JWT signature verification via JWKS (RFC 7517)#65
kalidke wants to merge 4 commits into
mainfrom
feature/jwks-validator

Conversation

@kalidke

@kalidke kalidke commented Jun 11, 2026

Copy link
Copy Markdown
Member

Summary

OAuth Resource Server hardening, phase 1 of the 0.6 plan (issue #51 context): JWTs from external authorization servers can now be cryptographically verified against the server's published JWKS. Previously JWTValidator validated claims only (documented limitation since #42).

  • New JWKSValidator <: TokenValidator (exported): verifies RSA signatures via JWTs.jl, then applies the same fail-closed claim validation as JWTValidator (iss/aud/exp/nbf/scopes, reused via validate_jwt_claims).
  • Header gate before any cryptography: malformed tokens, alg=none, non-allowlisted algorithms (default RS256/RS384/RS512), and missing kid are all rejected up front.
  • Key rotation with DoS protection: unknown kid triggers a JWKS re-fetch, rate-limited to one attempt per refresh_interval_seconds (default 300) — JWTs.jl's built-in auto-refresh-per-unknown-kid is deliberately bypassed because it lets attacker-supplied kid values hammer the JWKS endpoint. Fetches use bounded HTTP timeouts (10s connect/read) and run outside the validator lock so a hanging AS cannot block all authentication.
  • Lazy loading: construction never touches the network — a server can start while its AS is down; requests fail closed until keys load.
  • file:// JWKS and pre-built JWTs.JWKSet injection supported (tests, air-gapped).

New dependency: JWTs.jl 0.3 (JuliaWeb; brings JSON.jl + MbedTLS transitively). MbedTLS added to test extras for fixture signing.

Security cases covered by tests (28 new, suite 893/893)

  • kid spoofing (token signed by key B claiming kid A) → rejected
  • tampered payload with original signature → rejected
  • corrupted / empty signature → rejected
  • alg=none, HS256 (not allowlisted), missing kid, malformed → rejected pre-crypto
  • expired / wrong-audience / missing-scope after valid signature → rejected
  • rotation: new kid validates after re-fetch; within rate-limit window fails closed while cached keys keep working
  • fetch_jwks_keys fail-closed on bad JSON / missing keys / missing file
  • end-to-end AuthMiddleware + allowlist deny

Test fixtures are freshly generated, committed, test-only RSA keypairs (never used anywhere else).

Versioning

Additive → ships in 0.5.5 (per the 0.5.x policy). The JWKS-by-default flip for JWTValidator remains clustered in 0.6.0.

🤖 Generated with Claude Code

kalidke and others added 2 commits June 10, 2026 21:12
OAuth RS hardening, phase 1 of the 0.6 plan. JWTValidator remains
claims-only for trusted-issuer setups; JWKSValidator is now the
recommended path for tokens from external authorization servers.

- Signature verification through JWTs.jl (RS256/RS384/RS512 allowlist,
  alg=none rejected pre-crypto, kid required)
- Lazy key loading; unknown-kid refresh for rotation, rate-limited
  (default 300s) against kid-spray JWKS hammering; HTTP fetches use
  bounded timeouts and run outside the validator lock
- file:// and static JWKSet support for tests/air-gapped setups
- 28 new tests incl. kid spoofing, tampered payload/signature, rotation,
  rate limiting, claims enforcement after valid signature, middleware
  end-to-end with allowlist; committed test-only RSA fixtures

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses 4 WARNs from the adversarial review (no BLOCKs found):

- Reject plaintext http:// JWKS URLs at construction (MITM key swap);
  opt-in via allow_insecure_http=true for localhost/testing
- Streaming response size cap (MAX_JWKS_BYTES=1MB) + file-size pre-check;
  read aborts once exceeded so an unbounded/oversize body can't exhaust
  memory. Fixed HTTP.open body capture (returns Response, not closure value)
- Wrap JWTs.refresh! so a malformed upstream JWK entry fails auth closed
  and retains cached keys instead of erroring the request
- Reject present-but-non-numeric nbf in validate_jwt_claims (was silently
  ignored, letting a string nbf bypass the not-before gate) — also
  hardens JWTValidator which shares this path
- Clamp negative refresh_interval_seconds to 0

+13 tests (http rejection, oversize file+http, malformed JWK, nbf,
live streaming fetch). Suite 906/906.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kalidke

kalidke commented Jun 11, 2026

Copy link
Copy Markdown
Member Author

Codex security review — addressed

Ran an adversarial security review (Codex) on the validator. No BLOCKs, and it confirmed the core design holds against the main JWT bypass classes: kid is lookup-only (never reaches the fetch URL), the alg allowlist runs before any cryptography, JWTs.validate! cross-checks the token alg against the resolved key's algorithm (so RS/HS confusion and alg=none/signature-strip don't validate), and a fresh JWT is built per request (no isverified reuse hazard).

Fixed the 4 actionable WARNs in ea5773c:

Finding Fix
Plaintext http:// JWKS allows MITM key swap Rejected at construction; allow_insecure_http=true opt-in for localhost/testing. https:///file:// always allowed
No byte cap on JWKS response/file; refresh_interval_seconds could be ≤0 Streaming read aborts past MAX_JWKS_BYTES (1 MB) + file:// size pre-check; negative interval clamped to 0
Malformed JWK entry throws out of JWTs.refresh! → 500 Wrapped; fails auth closed and retains cached keys
Non-numeric nbf silently ignored Rejected as malformed (also hardens JWTValidator, shared path)

Deferred (documented): duplicate JSON member names in header/payload (WARN). This is not a local bypass — the signature binds the exact token bytes, and the package decodes each segment exactly once, so what's validated is what was signed. The residual risk is cross-component smuggling if a different component re-parses the token with first-wins semantics; detecting it requires a custom duplicate-key JSON scan (JSON3 collapses duplicates last-wins). Low value for the current single-validator path; will revisit if a second token-reparsing component is added. The Content-Type NIT was also skipped (JSON-parse + keys extraction already gate use).

Suite 906/906.

@kalidke kalidke marked this pull request as draft June 11, 2026 03:47
kalidke and others added 2 commits June 11, 2026 12:47
Real JWKS documents (Keycloak 26) publish use="enc" RSA-OAEP keys
alongside the signing keys; they can never verify a token and made
JWTs.refresh! warn on every fetch. fetch_jwks_keys now keeps only
use="sig" (or unspecified) entries. Found by the native-Keycloak
spike: JWKSValidator validated live Keycloak direct-grant tokens
end-to-end (vector aud, scope claim, alice principal) over a real
MCP HTTP server round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The streaming JWKS read bypasses HTTP.jl's transparent decompression,
so when a CDN/proxy (e.g. Cloudflare) gzips the response — HTTP.jl
advertises Accept-Encoding: gzip by default — the raw bytes failed
JSON parsing and every validation failed closed. Request identity
encoding explicitly. Found live: curl fetched the tunnel JWKS fine
while fetch_jwks_keys returned nothing (1932 compressed vs 2933
plain bytes). +1 regression test emulating the encoding contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant