OAuth.jl
August 16, 2026 · View on GitHub
OAuth.jl is a pure Julia toolkit for building OAuth 2.x clients and servers. It includes helpers for PKCE, DPoP, device authorization, pushed authorization requests (PAR), JWT authorization requests (JAR), resource indicators, dynamic client registration, protected-resource middleware, token introspection, and more.
- Works with real-world metadata: discover protected resources, authorization servers, and token endpoints directly from
/.well-knowndocuments. - First-class support for sender-constrained tokens (DPoP), resource indicators, and
authorization_detailsobjects so access tokens stay bound to audiences and actions. - Batteries-included server primitives: metadata endpoints, JWT access token issuance, in-memory token stores, authorization/token endpoint builders, and middleware that validates bearer/DPoP tokens for you.
- Pure Julia API surface that plays nicely with
HTTP.jl, async tasks, and REPL-friendly workflows.
Installation
julia> ] add OAuth
OAuth.jl is tested against Julia 1.10 and newer.
⚠️ Upgrading to 3.0
3.0 tightens several defaults in the secure direction and fixes a specification bug that affected interoperability. Read this before upgrading a deployed service.
1. DPoP key thumbprints (jkt) change value. jwk_thumbprint now follows RFC 7638 §3.2
and digests only the required members for the key type. Previously every member was
hashed, so a JWK carrying the standard optional kid/alg/use members — which is what
most clients publish, and what OAuth.jl's own public_jwk emits — produced a thumbprint no
specification-compliant peer would agree with. If you issued DPoP-bound (cnf.jkt) access
tokens with an earlier version, their confirmation values are now stale and will fail
verification; re-issue them. If you were talking to a third-party authorization server,
DPoP was already broken and now works.
2. PKCE is required at the authorization endpoint, S256 only. build_authorization_endpoint
previously issued codes for requests with no code_challenge at all, and silently accepted
the plain method, despite its docstring claiming to enforce PKCE. Requests without a
challenge, or using plain, are now redirected back with error=invalid_request. To keep
the old behaviour for legacy clients:
AuthorizationEndpointConfig(
code_store = code_store,
redirect_uri_resolver = resolver,
consent_handler = consent,
require_pkce = false,
allowed_code_challenge_methods = ["S256", "plain"],
)
3. Introspection and revocation endpoints require an explicit authenticator. These
previously defaulted to AllowAllAuthenticator(), silently leaving them open to anyone who
could reach them — an open introspection endpoint lets an attacker test captured tokens, and
an open revocation endpoint lets anyone revoke any token they can name. Both RFC 7662 §2.1
and RFC 7009 §2.1 require client authentication. Omitting the argument is now an
ArgumentError, so you have to choose:
build_introspection_handler(store; authenticator = BasicCredentialsAuthenticator(credentials = creds))
build_revocation_handler(store; authenticator = AllowAllAuthenticator()) # explicit opt-out
4. TokenEndpointConfig rejects grant types the endpoint cannot serve. Passing something
like ["client_credentials"] used to be accepted and then answered with
unsupported_grant_type on every request; it now throws at construction.
New in 3.0: the built-in token endpoint implements the refresh_token grant (see
Token Endpoint Helpers), client secrets and PKCE verifiers are
compared in constant time, malformed access tokens produce 401 instead of an unhandled
500, and JWTAccessTokenIssuer can finally derive an EC public JWK on its own.
Contents
Client
OAuth.jl exposes a high-level client API that balances ergonomics (loopback listeners, browser launch helpers, refresh token persistence) with access to raw OAuth features when you need them.
Discovery & Configuration
Use discovery helpers to bootstrap configurations from protected resource metadata or directly from an issuer. Both PublicClientConfig and ConfidentialClientConfig understand scopes, resource indicators, authorization details, additional query parameters, DPoP, and persistent refresh tokens.
using OAuth, JSON
# Resolve both the protected resource and authorization server metadata.
discovery = discover_oauth_metadata(
"https://api.example/.well-known/oauth-protected-resource";
issuer = "https://id.example",
)
# Optional DPoP and request-object signers.
dpop = DPoPAuth(
private_key = read("keys/dpop-private.pem", String),
public_jwk = JSON.parse(read("keys/dpop-public.jwk", String)),
)
request_signer = RequestObjectSigner(
private_key = read("keys/request-object.pem", String),
alg = :RS256,
kid = "request-key",
)
client = PublicClientConfig(
client_id = "desktop-app",
redirect_uri = "http://127.0.0.1:8765/callback",
scopes = ["openid", "profile", "payments.read"],
resources = ["https://api.example"],
authorization_details = [Dict("type" => "payment", "actions" => ["read"], "locations" => ["https://api.example"])],
additional_parameters = Dict("prompt" => "consent"),
dpop = dpop,
refresh_token_store = InMemoryRefreshTokenStore(),
use_par = true,
request_object_signer = request_signer,
)
# Now use the client config to start an authorization flow:
result = complete_pkce_authorization(
"https://api.example/.well-known/oauth-protected-resource",
client;
open_browser = true,
)
access_token = result.token
Confidential clients are configured the same way but add token-endpoint credentials:
service_app = ConfidentialClientConfig(
client_id = "microservice",
credential = PrivateKeyJWTAuth(
private_key = read("keys/private-jwt.pem", String),
alg = :RS256,
audience = "https://id.example/token",
kid = "svc-key",
),
scopes = ["jobs.write"],
resources = ["https://api.example"],
authorization_details = nothing,
)
# Use the confidential client config to request a service-to-service token:
discovery = discover_oauth_metadata_from_issuer("https://id.example")
token = request_client_credentials_token(
discovery.authorization_server,
service_app;
extra_token_params = Dict("resource" => "https://api.example/processor"),
)
Authorization Code + PKCE
start_pkce_authorization bootstraps an interactive authorization session. OAuth.jl can manage a loopback HTTP listener automatically, generate a PKCE verifier/challenge, push authorization requests (PAR) when required, and optionally launch the user’s browser.
session = start_pkce_authorization(
"https://api.example/.well-known/oauth-protected-resource",
client;
open_browser = false,
wait = false,
listener_port = 8765,
)
println("Open this URL in your browser: \n$(session.authorization_url)")
callback = wait_for_authorization_code(session; timeout = 180)
token = exchange_code_for_token(
session.authorization_server,
session.client_config,
callback.code,
session.verifier,
)
The convenience wrapper complete_pkce_authorization combines the start/wait/exchange phases if you're happy letting OAuth.jl open a browser window:
# Configure a refresh token store to persist tokens across sessions
client = PublicClientConfig(
client_id = "desktop-app",
redirect_uri = "http://127.0.0.1:8765/callback",
scopes = ["openid", "profile", "payments.read"],
refresh_token_store = InMemoryRefreshTokenStore(), # or use CallbackRefreshTokenStore for custom persistence
)
result = complete_pkce_authorization(
"https://api.example/.well-known/oauth-protected-resource",
client;
open_browser = true,
wait = true,
)
access_token = result.token
To persist refresh tokens between runs without wiring up callbacks, point a FileBasedRefreshTokenStore at a path on disk:
store = FileBasedRefreshTokenStore(expanduser("~/.config/myapp/refresh-token.json"))
client = PublicClientConfig(
client_id = "desktop-app",
redirect_uri = "http://127.0.0.1:8765/callback",
scopes = ["openid", "profile", "payments.read"],
refresh_token_store = store,
)
The file helper writes a tiny JSON blob—{"version":2,"encoding":"base64","refresh_token":"…"}—where the token body is base64 encoded so it is not sitting in obvious plain text when you open the file. Base64 only provides light obfuscation, so rely on the filesystem for real protection; on POSIX platforms the store calls chmod 0o600 by default, and you can pass permissions = nothing to skip that step if you need to manage permissions yourself.
Each read/write is wrapped in FileWatching.mkpidlock, so multiple Julia processes (or REPLs) pointing at the same file will serialize access. Override lock_path or stale_age when constructing the store if you need to place the pidfile somewhere else or adjust how aggressively stale locks are reclaimed.
Refresh tokens are persisted via the configured RefreshTokenStore, and the helpers automatically carry over resource and authorization_details to refresh requests. The simplest way is to pass the result directly:
refreshed_result = refresh_pkce_token(result)
# refreshed_result.token is the new TokenResponse with the refreshed access token
# refreshed_result.session, refreshed_result.callback, and refreshed_result.discovery are unchanged
You can also call the lower-level method directly, which returns just the TokenResponse:
refreshed_token = refresh_pkce_token(result.session.authorization_server, result.session.client_config)
# refreshed_token is a TokenResponse containing the new access token
Device Authorization Flow
Device/limited-input scenarios use the same configuration object. OAuth.jl posts to the authorization server’s device_authorization_endpoint, prints the verification URI/user code, and keeps polling until the user finishes consent.
device_flow = start_device_authorization(
"https://api.example/.well-known/oauth-protected-resource",
client,
)
println("Visit $(device_flow.device.verification_uri) and enter code $(device_flow.device.user_code)")
token = poll_device_authorization_token(
device_flow.authorization_server,
client,
device_flow.device;
sleep_function = sleep,
)
Client Credentials Grant
Confidential clients can request service-to-service tokens with any supported token-endpoint auth method (client_secret_basic, client_secret_jwt, private_key_jwt, or mTLS via TLSClientAuth).
discovery = discover_oauth_metadata_from_issuer("https://id.example")
token = request_client_credentials_token(
discovery.authorization_server,
service_app;
extra_token_params = Dict("resource" => "https://api.example/processor"),
)
Token Refresh, Resource Indicators & Authenticated Calls
Tokens capture resource indicators, authorization details, DPoP thumbprints, and expiration data via TokenResponse. Use oauth_request to send authenticated HTTP requests; it automatically injects the Authorization header, adds DPoP proofs when needed, and retries when the server asks for a nonce.
resp = oauth_request(
HTTP,
"GET",
"https://api.example/v1/payments";
token = token,
config = client, # supplies the DPoP key if token_type == "DPoP"
headers = HTTP.Headers(["Accept" => "application/json"]),
)
Dynamic Client Registration
Registration helpers enforce HTTPS, serialize metadata once, and return the raw JSON provided by the authorization server so you can persist registration and management endpoints.
metadata = fetch_authorization_server_metadata("https://id.example")
client_record = register_dynamic_client(
metadata,
Dict(
"redirect_uris" => ["http://127.0.0.1:8765/callback"],
"grant_types" => ["authorization_code"],
"token_endpoint_auth_method" => "client_secret_basic",
);
initial_access_token = "seed-token",
)
update_dynamic_client(
client_record["client_configuration_endpoint"],
Dict("client_name" => "Updated Demo App");
registration_access_token = client_record["registration_access_token"],
)
delete_dynamic_client(
client_record["client_configuration_endpoint"];
registration_access_token = client_record["registration_access_token"],
)
Server
Server-side helpers cover metadata exposure, authorization and token endpoints, JWT access token issuance, DPoP validation, introspection/revocation, and middleware that gates HTTP.jl handlers behind OAuth scopes.
Publishing OAuth Metadata
Expose protected-resource and authorization-server metadata straight from a router. The helpers validate HTTPS URLs, normalize string vectors, and keep documents on the canonical /.well-known paths.
using HTTP, OAuth, JSON
router = HTTP.Router()
resource_cfg = ProtectedResourceConfig(
resource = "https://api.example",
authorization_servers = ["https://id.example"],
scopes_supported = ["payments.read", "payments.write"],
)
register_protected_resource_metadata!(router, resource_cfg)
token_issuer = JWTAccessTokenIssuer(
issuer = "https://id.example",
audience = ["https://api.example"],
private_key = read("keys/token-signing.pem", String),
alg = :RS256,
kid = "token-key-1",
)
auth_server_cfg = AuthorizationServerConfig(
issuer = "https://id.example",
authorization_endpoint = "https://id.example/authorize",
token_endpoint = "https://id.example/token",
device_authorization_endpoint = "https://id.example/device_authorization",
jwks_uri = "https://id.example/.well-known/jwks.json",
scopes_supported = ["payments.read", "payments.write"],
code_challenge_methods_supported = ["S256"],
token_endpoint_auth_methods_supported = ["client_secret_basic", "private_key_jwt"],
request_object_signing_alg_values_supported = ["RS256", "ES256"],
)
register_authorization_server_metadata!(router, auth_server_cfg)
register_jwks_endpoint!(router, [public_jwk(token_issuer)])
JWT Access Token Issuance & Storage
JWTAccessTokenIssuer signs tokens, and the optional AccessTokenStore captures issued tokens for introspection and revocation. Server stores use the AbstractStores.jl interface. The application can therefore choose process memory, files, Redis, SQL, or another conforming backend. Tokens inherit scopes, authorization details, audiences, confirmation (cnf) claims, and extra custom claims in a single call.
A statically compiled service can build its signer before issuer construction:
signer = OAuth.rsa_signer_from_bytes(read("keys/token-signing.pem"))
token_issuer = JWTAccessTokenIssuer(
issuer="https://id.example",
audience=["https://api.example"],
signer=signer,
alg=:RS256,
kid="token-key-1",
)
Provide exactly one of private_key or signer. OAuth rejects an algorithm or
EC curve that does not match the signer.
token_store = InMemoryTokenStore()
issued = issue_access_token(
token_issuer;
subject = "user-123",
client_id = "desktop-app",
scope = ["payments.read"],
authorization_details = [Dict("type" => "payment", "actions" => ["read"])],
extra_claims = Dict("username" => "alice"),
store = token_store,
)
validator = TokenValidationConfig(
issuer = "https://id.example",
audience = ["https://api.example"],
jwks = Dict("keys" => [public_jwk(token_issuer)]),
)
claims = validate_jwt_access_token(issued.token, validator; required_scopes = ["payments.read"])
AccessTokenClaims includes the parsed scope list, audience, confirmation thumbprint, client ID, and raw claims you can use for authorization decisions.
Choosing a Server State Backend
The three authorization-server stores can share one physical backend. OAuth adds separate prefixes for access tokens, authorization codes, and refresh grants:
using AbstractStores, OAuth
backend = MemoryStore()
stores = OAuth.AuthorizationServerStores(backend)
token_store = stores.access_tokens
code_store = stores.authorization_codes
refresh_grant_store = stores.refresh_grants
Only the backend construction changes when the state must survive a restart or be shared between server processes:
backend = RedisStore{Any}(client) # or SQLStore{Any}(conn)
stores = OAuth.AuthorizationServerStores(backend)
AuthorizationServerStores is a typed configuration bundle. It does not own
the backend. Pass its fields to the endpoint configurations, or pass the bundle
directly to their convenience constructors. The older
authorization_server_stores(backend) helper remains available and returns the
same three stores as a named tuple.
Access-token claims use Dict{String,Any} by default. A trim-compiled service,
or a service that wants typed JSON persistence, can declare its exact claim
shape. Field names match JWT claim names. Optional fields include Nothing;
unknown custom claims are dropped. The shape must include each registered claim
that token minting uses. OAuth raises an error instead of silently dropping a
registered claim such as exp, scope, or cnf.
Base.@kwdef struct AccessClaims
iss::Union{Nothing,String} = nothing
sub::Union{Nothing,String} = nothing
aud::Union{Nothing,String,Vector{String}} = nothing
exp::Union{Nothing,Int64} = nothing
iat::Union{Nothing,Int64} = nothing
jti::Union{Nothing,String} = nothing
client_id::Union{Nothing,String} = nothing
scope::Union{Nothing,String} = nothing
tenant::Union{Nothing,String} = nothing
end
stores = OAuth.AuthorizationServerStores(MemoryStore(); claims=AccessClaims)
AuthorizationEndpointConfig and TokenEndpointConfig require an atomic,
TTL-capable authorization-code store, because RFC 6749 §4.1.2 requires a code to
be redeemable exactly once. MemoryStore, SQLStore, and RedisStore provide
that guarantee. FileStore has no cross-process compare-and-swap, so OAuth
rejects it for authorization codes at construction time rather than degrading
silently; it remains valid for access-token state in a single service process.
A shared backend must also hand values back unchanged, which means MemoryStore
or the default SerializedCodec. A portable codec such as JSONCodec decodes at
the backend's own eltype, so records would come back as Dict{String,Any}.
With such a codec, give each kind of state its own concretely typed store:
stores = OAuth.AuthorizationServerStores(
access_tokens=FileStore{OAuth.AccessTokenRecord{AccessClaims}}(
access_dir;
codec=JSONCodec(),
),
authorization_codes=FileStore{OAuth.AuthorizationCodeRecord}(code_dir; codec=JSONCodec()),
refresh_grants=FileStore{OAuth.RefreshTokenGrantRecord}(refresh_dir; codec=JSONCodec()),
)
Expired entries are always invisible to lookups, but reclaiming them is the
backend's business. Redis and SQL expire rows on their own, while MemoryStore
and FileStore only drop an expired entry when something reads it. A
long-running authorization server on those backends should call
AbstractStores.sweep!(backend) on a timer so unredeemed codes and lapsed tokens
do not accumulate.
OAuth hashes bearer tokens, authorization codes, and refresh tokens before it uses them as store keys. The raw credentials remain inside the stored records. They do not appear in filenames or database indexes.
The client-side RefreshTokenStore API remains separate. It includes
configuration-aware lookup and refresh locking that a simple key-value store
does not model.
Protected Resource Middleware
Wrap any HTTP.jl handler so it automatically validates the Authorization header, enforces scopes, checks DPoP proofs, and places the decoded token on the request context.
function payments_handler(req)
claims = req.context[:oauth_token]
body = JSON.json(Dict("subject" => claims.subject, "scope" => claims.scope))
return HTTP.Response(200, ["Content-Type" => "application/json"], body)
end
secured_handler = protected_resource_middleware(
payments_handler,
validator;
resource_metadata_url = "https://api.example/.well-known/oauth-protected-resource",
required_scopes = ["payments.read"],
sender_constrained_only = false,
dpop_nonce_validator = (_nonce, _req) -> true,
)
HTTP.register!(router, "GET", "/payments", secured_handler)
When the incoming token is sender constrained, OAuth.jl verifies the accompanying DPoP proof (htu/htm/ath), enforces nonce policies, and rejects replayed JTIs via DPoPReplayCache.
Authorization Endpoint Helpers
build_authorization_endpoint wires together redirect validation, consent collection, PKCE enforcement, and authorization-code issuance. You implement two small callbacks: a redirect resolver and a consent handler that returns AuthorizationGrantDecision.
code_store = InMemoryAuthorizationCodeStore()
auth_endpoint = build_authorization_endpoint(
AuthorizationEndpointConfig(
code_store = code_store,
redirect_uri_resolver = (_req, client_id, requested) -> begin
whitelist = Dict("desktop-app" => "http://127.0.0.1:8765/callback")
requested === nothing ? whitelist[client_id] : requested
end,
consent_handler = (_req, ctx) -> begin
println("User granted scopes: $(ctx.scope)")
return grant_authorization("user-123"; scope = ctx.scope, authorization_details = ctx.authorization_details)
end,
),
)
HTTP.register!(router, "GET", "/authorize", auth_endpoint)
AuthorizationRequestContext contains the normalized request (client ID, redirect URI, scope/resource arrays, PKCE code challenge/method, and arbitrary params), so your consent handler can add custom claims or deny requests with helpful messages via deny_authorization.
PKCE is required by default, and only the S256 challenge method is accepted, matching the OAuth 2.0 Security BCP (RFC 9700) and OAuth 2.1. Requests without a code_challenge, or using plain, are redirected back with error=invalid_request. If you must support legacy clients, opt out explicitly:
AuthorizationEndpointConfig(
code_store = code_store,
redirect_uri_resolver = resolver,
consent_handler = consent,
require_pkce = false, # allow requests with no code_challenge
allowed_code_challenge_methods = ["S256", "plain"], # accept the plain method too
)
Token Endpoint Helpers
The token endpoint builder consumes the authorization codes created above, issues JWTs, saves them to your store, and optionally returns refresh tokens. Bring your client-store map as an authenticator.
client_auth = client_credentials_authenticator(Dict("desktop-app" => "secret"))
token_endpoint = build_token_endpoint(
TokenEndpointConfig(
code_store = code_store,
token_issuer = token_issuer,
client_authenticator = client_auth,
token_store = token_store,
refresh_token_generator = (record, _client) -> random_state(),
extra_token_claims = (record, _client) -> Dict("auth_time" => OAuth.datetime_to_unix(record.issued_at)),
allowed_grant_types = ["authorization_code"],
),
)
HTTP.register!(router, "POST", "/token", token_endpoint)
You receive a TokenEndpointClient describing the authenticated client, and the helper automatically enforces PKCE, validates redirect URIs, and copies authorization details/resource indicators into the response.
To also serve the refresh_token grant, combine the issuer and stores in a
TokenService. The service owns access-token persistence, refresh-token
issuance, rotation, and revocation. The issuer remains responsible only for JWT
construction and signing:
stores = AuthorizationServerStores(MemoryStore())
token_service = TokenService(
stores;
issuer=token_issuer,
refresh_token_ttl_seconds=60 * 60 * 24 * 30,
)
token_endpoint = build_token_endpoint(
TokenEndpointConfig(
stores,
token_service;
client_authenticator=client_auth,
allowed_grant_types=["authorization_code", "refresh_token"],
),
)
TokenService refresh tokens are rotated on every use. A replayed old token
revokes its active token family and fails with invalid_grant (RFC 9700 §4.14).
The grant is bound to its client, keeps its original absolute expiry, and lets a
client narrow scope but never widen it (RFC 6749 §6).
Clients must serialize refresh requests and must not retry one blindly after a timeout. Reusing the pre-rotation token is treated as a replay and revokes the active family. The user must then authenticate again.
The existing keyword-only TokenEndpointConfig API remains available for
custom or opaque refresh-token formats. Those tokens still rotate on every use,
but they cannot identify and revoke a newer member of the same token family
after replay.
Introspection & Revocation
OAuth.jl exposes ready-to-mount handlers for RFC 7662 introspection and RFC 7009 revocation. Both require an explicit authenticator — leaving one of these endpoints open lets anyone test captured tokens or revoke tokens they can name, so the choice is deliberate rather than a default. Pass HTTP Basic credentials, or AllowAllAuthenticator() to opt out for tests and localhost.
introspect_auth = BasicCredentialsAuthenticator(
credentials = Dict("resource" => "topsecret"),
realm = "token",
)
HTTP.register!(router, "POST", "/introspect", build_introspection_handler(token_store; authenticator = introspect_auth))
HTTP.register!(router, "POST", "/revoke", build_revocation_handler(token_store; authenticator = introspect_auth))
Running the server
Once routes are registered, start serving with HTTP.serve(router, ip"0.0.0.0", 8080) or your favorite HTTP stack. All helpers return plain functions, so you can plug them into Genie.jl, Oxygen.jl, or any other framework that understands HTTP.Request.
Contributing
- Install Julia 1.10 or newer.
- Run the test suite with
julia --project -e 'using Pkg; Pkg.test()'. - Open a pull request that explains the motivation and behavior changes.
Bug reports and feature requests are welcome via GitHub issues.
License
This package is available under the terms of the MIT "Expat" License.