Skip to content
Merged
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ jobs:
name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
RESEAU_PRECOMPILE_ONLY: ${{ matrix.arch == 'x86' && 'none' || '' }}
strategy:
fail-fast: false
matrix:
Expand Down
6 changes: 3 additions & 3 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ authors = ["Tanmay Mohapatra <tanmaykm@gmail.com>", "contributors: https://githu
version = "1.1.0"

[deps]
Downloads = "f43a241f-c20a-4ad4-852c-f6b1247861c6"
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
OpenSSL_jll = "458c3c95-2e84-50aa-8efc-19380b2a3a95"
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"

[compat]
Downloads = "1"
HTTP = "2"
JSON = "0.20, 0.21, 1"
OpenSSL_jll = "3"
julia = "1.6"
julia = "1.10"

[extras]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,16 @@ Supported verifier options include:
- `max_age`: maximum token age from `iat`
- `required_claims`: claims that must be present
- `now`: injectable clock, useful for deterministic tests
- `claims`: a concrete payload type for typed decoding and static compilation

In a trim-compiled entrypoint, put the claim type first so normal Julia dispatch
keeps it concrete: `JWTs.Verifier(MyClaims, keyset; algorithms=["RS256"])`.
Normal Julia code can also use the `claims=MyClaims` keyword. Typed decoding
requires JSON.jl 1.

`aud` may be either a string or an array of strings, matching RFC 7519.

`JWTs.VerifiedJWT` exposes the original parsed token as `verified.token`, the decoded header as `verified.header`, the decoded claims as `verified.claims`, and the matched verification key as `verified.key`. The convenience accessors `JWTs.claims(verified)`, `JWTs.kid(verified)`, and `JWTs.alg(verified)` are also available.
`JWTs.VerifiedJWT` exposes the original parsed token as `verified.token`, the typed `JWTHeaderClaims` header as `verified.header`, the decoded claims as `verified.claims`, and the matched verification key as `verified.key`. The convenience accessors `JWTs.claims(verified)`, `JWTs.kid(verified)`, and `JWTs.alg(verified)` are also available. A verifier rejects JOSE `crit` and `b64` extension headers because this package does not implement extension-header processing.

## Remote JWKS

Expand Down Expand Up @@ -150,7 +156,7 @@ fetcher = url -> read("fixtures/jwks.json", String)
verifier = JWTs.Verifier(; jwks_uri="https://issuer.example/keys", algorithms=["RS256"], fetcher=fetcher)
```

The default fetcher uses Downloads.jl. Pass `downloader=Downloads.Downloader()` when you want to reuse a configured Downloads downloader, or pass `fetcher=url -> ...` when tests or applications need full control over network access. The same keywords are available on `JWTs.refresh!(keyset)` for direct `JWKSet` refreshes.
The default fetcher uses HTTP.jl. Pass `fetcher=url -> ...` when tests or applications need custom transport, authentication, or fixture behavior. The old `downloader` keyword is still accepted so old calls fail with a clear migration error instead of a method error. The same keywords are available on `JWTs.refresh!(keyset)` for direct `JWKSet` refreshes.

## OpenID Connect Discovery

Expand Down
182 changes: 151 additions & 31 deletions src/JWTs.jl
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
module JWTs

using JSON
using Downloads
using HTTP
using OpenSSL_jll
using SHA

Expand All @@ -27,6 +27,7 @@ if VERSION >= v"1.11"
:JWKSError,
:parse_keyfile,
:claims,
:claimstype,
:kid,
:alg,
:issigned,
Expand Down Expand Up @@ -240,6 +241,9 @@ end
Base.@kwdef struct JWTHeaderClaims
alg::Union{Nothing,String} = nothing
kid::Union{Nothing,String} = nothing
typ::Union{Nothing,String} = nothing
crit::Union{Nothing,Missing,Vector{String}} = nothing
b64::Union{Nothing,Missing,Bool} = nothing
end

function jwt_string_claim(claims::AbstractDict, claim::String)::Union{Nothing,String}
Expand All @@ -248,6 +252,27 @@ function jwt_string_claim(claims::AbstractDict, claim::String)::Union{Nothing,St
return value
end

function jwt_string_array_claim(claims::AbstractDict, claim::String)::Union{Nothing,Missing,Vector{String}}
haskey(claims, claim) || return nothing
value = claims[claim]
value === nothing && return missing
value isa AbstractVector || throw(ArgumentError("jwt header $claim must be an array of strings"))
result = String[]
for item in value
item isa AbstractString || throw(ArgumentError("jwt header $claim must be an array of strings"))
push!(result, String(item))
end
return result
end

function jwt_bool_claim(claims::AbstractDict, claim::String)::Union{Nothing,Missing,Bool}
haskey(claims, claim) || return nothing
value = claims[claim]
value === nothing && return missing
value isa Bool || throw(ArgumentError("jwt header $claim must be a boolean"))
return value
end

function jwt_header_string_claim(encoded::String, claim::String)::Union{Nothing,String}
if !applicable(JSON.parse, "", JWTHeaderClaims)
return jwt_string_claim(decode_jwt_json_object(encoded), claim)
Expand All @@ -258,6 +283,27 @@ function jwt_header_string_claim(encoded::String, claim::String)::Union{Nothing,
return nothing
end

# Project a dynamically parsed header onto the registered JOSE members. Keep
# this separate so the pre-JSON-1 compatibility path can be tested on JSON 1.
function decode_jwt_header_claims_untyped(encoded::String)::JWTHeaderClaims
header = decode_jwt_json_object(encoded)
return JWTHeaderClaims(
alg=jwt_string_claim(header, "alg"),
kid=jwt_string_claim(header, "kid"),
typ=jwt_string_claim(header, "typ"),
crit=jwt_string_array_claim(header, "crit"),
b64=jwt_bool_claim(header, "b64"),
)
end

# Decode a JOSE header into the typed `JWTHeaderClaims`. The typed
# `JSON.parse` needs JSON.jl 1; older JSON versions read the object
# dynamically and project the registered members onto the struct.
function decode_jwt_header_claims(encoded::String)::JWTHeaderClaims
applicable(JSON.parse, "", JWTHeaderClaims) && return decodepart(encoded, JWTHeaderClaims)
return decode_jwt_header_claims_untyped(encoded)
end

"""
claims(jwt::JWT)

Expand Down Expand Up @@ -474,61 +520,135 @@ function refresh!(keyset::JWKSet; default_algs = Dict("RSA" => "RS256", "oct" =>
nothing
end

function jwks_document(raw, url::String)
"""
JWKSKey

The RFC 7517/7518 JWK members the key-set refresh reads, each an optional
string. Unknown members (`x5c`, `key_ops`, …) are skipped by the typed parse.
An entry parsed into this shape is read with concretely typed member accesses
— a `Dict{String,Any}` element type here would make every member read (and
the error-display machinery behind `make(::Type{Any})`) dynamic under
`juliac --trim`.
"""
Base.@kwdef struct JWKSKey
kid::Union{Nothing,String} = nothing
kty::Union{Nothing,String} = nothing
alg::Union{Nothing,String} = nothing
crv::Union{Nothing,String} = nothing
n::Union{Nothing,String} = nothing
e::Union{Nothing,String} = nothing
k::Union{Nothing,String} = nothing
x::Union{Nothing,String} = nothing
y::Union{Nothing,String} = nothing
end

"""
JWKSDocument

The RFC 7517 JWK Set document shape: a JSON object with a `keys` array.
Fetched documents parse into this type rather than an untyped JSON object, so
the key-set refresh path stays concretely typed — which statically compiled
(`juliac --trim`) consumers need.
"""
Base.@kwdef struct JWKSDocument
keys::Union{Nothing,Vector{JWKSKey}} = nothing
end

# One member of a JWK entry, by its RFC name. Entries arrive either as plain
# dicts (the public `refresh!(keys::Vector, …)`/`JWKSet(::Vector)` path — the
# dict reads keep their original KeyError/TypeError behavior) or as the typed
# `JWKSKey` from a fetched document; call sites pass literal names, so the
# struct read constant-folds to a field access.
jwk_member(key::AbstractDict, name::String)::String = key[name]::String
function jwk_member(key::JWKSKey, name::String)::String
value = getfield(key, Symbol(name))
value === nothing && throw(KeyError(name))
return value
end
jwk_optional_member(key::AbstractDict, name::String)::Union{Nothing,String} =
haskey(key, name) ? key[name]::String : nothing
jwk_optional_member(key::JWKSKey, name::String)::Union{Nothing,String} = getfield(key, Symbol(name))

# The `keys` member of a fetched JWKS document, concretely typed. A custom
# fetcher may hand back an already-parsed object — that arm stays dynamic by
# design. String and byte documents go through the typed parse on JSON.jl 1,
# with the untyped read as the pre-1 fallback. (The RemoteJWKSet path has its
# own `jwks_keys(doc, url)` over pre-parsed documents in remote_jwks.jl.)
function fetched_jwks_keys(raw, url::String)
if raw isa AbstractDict
return raw
elseif raw isa AbstractString
return JSON.parse(String(raw))
elseif raw isa AbstractVector{UInt8}
return JSON.parse(String(raw))
else
keys = get(raw, "keys", nothing)
keys isa AbstractVector || throw(ArgumentError("JWKS document from $url must contain a \"keys\" array"))
return keys
end
raw isa AbstractString || raw isa AbstractVector{UInt8} ||
throw(ArgumentError("unsupported JWKS document result from $url: $(typeof(raw))"))
json = String(raw)
if applicable(JSON.parse, json, JWKSDocument)
document = JSON.parse(json, JWKSDocument)
keys = document.keys
keys === nothing && throw(ArgumentError("JWKS document from $url must contain a \"keys\" array"))
return keys
end
return fetched_jwks_keys_untyped(json, url)
end

function fetched_jwks_keys_untyped(json::String, url::String)
document = JSON.parse(json)
document isa AbstractDict || throw(ArgumentError("JWKS document from $url must be a JSON object"))
keys = get(document, "keys", nothing)
keys isa AbstractVector || throw(ArgumentError("JWKS document from $url must contain a \"keys\" array"))
return keys
end

function fetch_url(url::String; downloader=nothing)
downloader === nothing || throw(ArgumentError(
"the downloader keyword is not supported by the HTTP.jl fetch path; pass fetcher=url -> ... instead"))
if startswith(url, "file://")
return readchomp(url[8:end])
else
output = PipeBuffer()
response = Downloads.request(url; method="GET", output=output, downloader=downloader)
# Downloads.request only throws on transport-level errors, not on HTTP error
# status codes, so a 4xx/5xx error page would otherwise be parsed as a keyset.
if response isa Downloads.Response && !(200 <= response.status < 300)
response = HTTP.get(url; status_exception=false)
# A 4xx/5xx error page must not be parsed as a keyset.
200 <= response.status < 300 ||
throw(ErrorException("failed to fetch $url: HTTP status $(response.status)"))
end
return String(take!(output))
return String(response.body)
end
end

function refresh!(keyseturl::String, keysetdict::Dict{String,JWK}; default_algs = Dict("RSA" => "RS256", "oct" => "HS256"), downloader=nothing, fetcher=nothing, allow_symmetric=nothing)
raw = fetcher === nothing ? fetch_url(keyseturl; downloader=downloader) : fetcher(keyseturl)
keys = jwks_document(raw, keyseturl)["keys"]
keys = fetched_jwks_keys(raw, keyseturl)
allow_symmetric = something(allow_symmetric, !is_http_url(keyseturl))
refresh!(keys, keysetdict; default_algs=default_algs, allow_symmetric=allow_symmetric)
end

function default_jwk_alg(key, default_algs)
haskey(key, "alg") && return key["alg"]
kty = key["kty"]
# RFC 7517 JWK members are strings; reading them through `jwk_member` (a
# `::String`-asserted dict lookup, or a typed `JWKSKey` field) keeps every
# downstream call (JWK construction, base64 decoding, keyset insertion)
# concretely typed — required for `juliac --trim` — while a malformed member
# lands in `refresh!`'s existing per-key skip handling.
function default_jwk_alg(key, default_algs::Dict{String,String})::String
alg = jwk_optional_member(key, "alg")
alg !== nothing && return alg
kty = jwk_member(key, "kty")
if kty in ("EC", "OKP")
return alg_for_curve(key["crv"])
return alg_for_curve(jwk_member(key, "crv"))
else
return get(default_algs, kty, "none")
end
end

function refresh!(keys::Vector, keysetdict::Dict{String,JWK}; default_algs = Dict("RSA" => "RS256", "oct" => "HS256"), allow_symmetric::Bool=true)
default_algs_str = convert(Dict{String,String}, default_algs)
for key in keys
kid = key["kid"]
kty = key["kty"]
alg = default_jwk_alg(key, default_algs)
kid = jwk_member(key, "kid")
kty = jwk_member(key, "kty")
alg = default_jwk_alg(key, default_algs_str)

# ref: https://tools.ietf.org/html/rfc7518
try
if kty == "RSA"
n = base64url_decode(key["n"])
e = base64url_decode(key["e"])
n = base64url_decode(jwk_member(key, "n"))
e = base64url_decode(jwk_member(key, "e"))
if alg in RSA_ALGORITHMS
keysetdict[kid] = JWKRSA(alg, rsa_public_key(n, e))
else
Expand All @@ -540,26 +660,26 @@ function refresh!(keys::Vector, keysetdict::Dict{String,JWK}; default_algs = Dic
@warn("symmetric keys are not accepted from this key source, skipping key $kid")
continue
end
k = base64url_decode(key["k"])
k = base64url_decode(jwk_member(key, "k"))
if alg in HMAC_ALGORITHMS
keysetdict[kid] = JWKSymmetric(alg, k)
else
@warn("key alg $alg not supported yet, skipping key $kid")
continue
end
elseif kty == "EC"
crv = key["crv"]
x = base64url_decode(key["x"])
y = base64url_decode(key["y"])
crv = jwk_member(key, "crv")
x = base64url_decode(jwk_member(key, "x"))
y = base64url_decode(jwk_member(key, "y"))
if alg in EC_ALGORITHMS
keysetdict[kid] = JWKEC(alg, ec_public_key(crv, x, y), crv)
else
@warn("key alg $alg not supported yet, skipping key $kid")
continue
end
elseif kty == "OKP"
crv = key["crv"]
x = base64url_decode(key["x"])
crv = jwk_member(key, "crv")
x = base64url_decode(jwk_member(key, "x"))
if alg in OKP_ALGORITHMS
keysetdict[kid] = JWKOKP(alg, okp_public_key(crv, x), crv)
else
Expand Down
Loading
Loading