Skip to content

Typed claims, header, and JWKS document decoding for statically compiled verifiers - #42

Merged
quinnj merged 9 commits into
masterfrom
trim-typed-jwks
Aug 16, 2026
Merged

Typed claims, header, and JWKS document decoding for statically compiled verifiers#42
quinnj merged 9 commits into
masterfrom
trim-typed-jwks

Conversation

@quinnj

@quinnj quinnj commented Aug 15, 2026

Copy link
Copy Markdown
Member

Makes every decode on the verification path concretely typed, so a server compiled with juliac --trim=safe verifies with zero JWTs-owned errors (JuliaCon 2026 workshop app; the residual set there is now Base toolchain machinery only). Five commits, each keeping the default dynamic path unchanged:

Verifier{S,F} → parametric on key source and clock (earlier commits): a verifier over a static keyset no longer reaches the remote-JWKS machinery statically, and remote key sets are fetched with HTTP.jl instead of Downloads (the downloader keyword is accepted and ignored; fetcher remains the customization point) — dropping libcurl from every consumer.

Application-declared claims type: Verifier(...; claims=MyClaims) decodes payloads with JSON.parse(payload, MyClaims), and every validation read goes through one accessor seam (claimvalue/hasclaim: dict lookup or struct field), so claim_string/claim_number/claim_audiences never see an Any. VerifiedJWT{C} carries the typed claims; claimstype(verifier) reports it; the default remains Dict{String,Any}. Mirrors the store-side design in JuliaServices/OAuth.jl#46.

Typed JOSE header: JWTHeaderClaims gains the registered typ member and verify decodes headers into it (with the pre-JSON-1 dict fallback the code already used for jwt_header_string_claim). VerifiedJWT.header is now a JWTHeaderClaims — a public field type change.

Typed JWKS documents: fetched key-set documents parse as JWKSDocument{keys::Vector{JWKSKey}}, where JWKSKey is one optional String per RFC 7517/7518 member the refresh reads (unknown members like x5c are skipped). refresh!/default_jwk_alg read members through a two-method seam — the public Vector-of-dicts path keeps its exact KeyError/TypeError behavior; the typed path constant-folds to field accesses. The custom-fetcher arm and pre-JSON-1 fallback stay dynamic by design. Note for reviewers: a first cut typed the keys as Vector{Dict{String,Any}}, which made things far worse under trim (Dict{String,Any} as a typed-parse target drags make(::Type{Any}) and its error-display machinery into the graph) — the member struct is the shape that verifies.

Suite green throughout, including the package's own trim harness; a typed-verifier test covers the declared-claims path end to end.

🤖 Generated with Claude Code

Review follow-up

  • Use the type-first Verifier(MyClaims, ...) API for static claim types. No Val API is used.
  • Reject unsupported JOSE critical and unencoded-payload headers.
  • Preserve missing versus null audience errors and cover remote typed verifiers.
  • Raise the Julia floor to 1.10 to match HTTP 2.
  • Keep the 32-bit package job, but skip Reseau's unsupported precompile workload on that architecture.

Validation: Julia 1.10 and current suites pass. The current suite has 2,704 checks. The typed verification trim workload builds and runs with zero verifier errors. A clean local coverage run reports 100% coverage for changed source lines.

Co-authored by Codex

quinnj and others added 9 commits August 14, 2026 15:34
refresh! and default_jwk_alg read RFC 7517 JWK members out of JSON dicts as
untyped Any values, which made every downstream call — base64url decoding,
JWK construction, keyset insertion — dynamically dispatched and
unresolvable under juliac --trim=safe (68 of the verify errors in a
trim-compiled server whose auth verifier is built from a JWK set). JWK
members are strings by RFC, so ::String asserts on each member lookup turn
the whole per-key path into statically resolvable calls; a malformed member
now lands in refresh!'s existing per-key skip handling as a TypeError.
default_algs is normalized once to Dict{String,String}. Accepts JWK dicts
of any AbstractDict type as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verifier stored its keyset as the abstract VerifierKeySource and its clock
as ::Function, which type-erases both: a verifier built over a static
in-memory keyset still made the whole remote-JWKS/OIDC refresh machinery
statically reachable, and every now() call dispatched dynamically — eight
verify errors in a juliac --trim server that never fetches remote keys.
Verifier{S,F} keeps construction and the public API unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
refresh!(url) indexed the parsed JWKS document as Any, and claim_audiences
branched on AbstractString/AbstractVector; parsed JSON yields concrete
String / Vector, so narrowing to those keeps both paths statically
dispatched (the abstract fallbacks remain for non-JSON callers). Malformed
JWKS documents now raise a clear ArgumentError instead of a KeyError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Downloads was a hard dependency used only by fetch_url, so every JWTs
consumer linked libcurl and its timer callbacks — two verify errors in a
juliac --trim server that never fetches remote keys. HTTP.jl (already
present across the JuliaWeb/JuliaServices stack) replaces it; the
`downloader` keyword is accepted and ignored for compatibility (use
`fetcher` to customize retrieval).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Verifier gains a claims-type parameter mirroring OAuth's store
design: Verifier(...; claims=MyClaims) decodes the payload with
JSON.parse(payload, MyClaims), and every validation read goes through
one accessor seam (claimvalue/hasclaim: dict lookup or struct field),
so claim_string/claim_number/claim_audiences never see an Any.
VerifiedJWT{C} carries the typed claims and claimstype(verifier)
reports it; the default Dict{String,Any} path is unchanged. The
audience check captures the narrowed local rather than the
Union{Nothing,...} field, and Verifier(keys::Vector; ...) forwards
keywords explicitly so the call resolves to the keyset constructor
alone instead of unioning over every keyword method.

The JOSE header now decodes into the typed JWTHeaderClaims - which
gains the registered typ member - instead of Dict{String,Any}, through
the same JSON-1-with-fallback route jwt_header_string_claim already
uses. VerifiedJWT.header changes type accordingly, and the alg/kid
validation reads become typed field accesses.

Under juliac --trim=safe this removes the JWTs-owned payload-decode
errors and the header decodepart error in a statically compiled server;
the refresh!-reachability pair on static key sets is a separate API
question and untouched here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The URL-refresh path read fetched documents through an untyped
JSON.parse merged with the custom-fetcher escape hatch, so the document
was an AbstractDict, its "keys" member an AbstractVector, and the
keyword call into refresh!(keys::Vector, ...) unresolvable under
juliac --trim - the one remaining JWTs-owned verify error in a
statically compiled server (the fetch itself was never the problem:
fetch_url returns a concrete String on both of its branches).

Fetched string/byte documents now parse as JWKSDocument, whose keys are
JWKSKey - one optional String per RFC 7517/7518 member the refresh
reads; unknown members (x5c, key_ops, ...) are skipped by the typed
parse. A first cut typed the keys as Vector{Dict{String,Any}}; that
reintroduced the known-toxic Dict{String,Any} parse target (makedict's
Any values plus the make(::Type{Any}) error-display machinery, +40
verify errors) and was replaced by the member struct. refresh! and
default_jwk_alg read members through jwk_member/jwk_optional_member: a
::String-asserted dict lookup with the original KeyError/TypeError
behavior for the public Vector-of-dicts path, or a constant-folded
field access for JWKSKey. The custom-fetcher arm (already-parsed
objects) and the pre-JSON-1 fallback stay dynamic by design.

Also: the typed-verifier test struct commits its aud to one arm
(String, matching how issuers write a single audience) and signs a
single-audience payload for the typed decode.

With this, JWTs contributes zero verify errors to the workshop app's
--trim=safe build on registered StructUtils; the residual set is Base
display/SIGINT machinery plus ScopedValues HAMT (julia#62730).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@quinnj
quinnj merged commit 4cfa299 into master Aug 16, 2026
10 checks passed
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