feat: JWKSValidator — JWT signature verification via JWKS (RFC 7517)#65
feat: JWKSValidator — JWT signature verification via JWKS (RFC 7517)#65kalidke wants to merge 4 commits into
Conversation
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>
Codex security review — addressedRan an adversarial security review (Codex) on the validator. No BLOCKs, and it confirmed the core design holds against the main JWT bypass classes: Fixed the 4 actionable WARNs in
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 + Suite 906/906. |
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>
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
JWTValidatorvalidated claims only (documented limitation since #42).JWKSValidator <: TokenValidator(exported): verifies RSA signatures via JWTs.jl, then applies the same fail-closed claim validation asJWTValidator(iss/aud/exp/nbf/scopes, reused viavalidate_jwt_claims).alg=none, non-allowlisted algorithms (defaultRS256/RS384/RS512), and missingkidare all rejected up front.kidtriggers a JWKS re-fetch, rate-limited to one attempt perrefresh_interval_seconds(default 300) — JWTs.jl's built-in auto-refresh-per-unknown-kid is deliberately bypassed because it lets attacker-suppliedkidvalues 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.file://JWKS and pre-builtJWTs.JWKSetinjection 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)
alg=none, HS256 (not allowlisted), missing kid, malformed → rejected pre-cryptofetch_jwks_keysfail-closed on bad JSON / missingkeys/ missing fileAuthMiddleware+ allowlist denyTest 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
JWTValidatorremains clustered in 0.6.0.🤖 Generated with Claude Code