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.
julia> ] add OAuthOAuth.jl is tested against Julia 1.10 and newer.
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-out4. 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.
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.
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.tokenConfidential 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"),
)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.tokenTo 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 unchangedYou 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 tokenDevice/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,
)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"),
)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"]),
)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-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.
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)])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.
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.
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_grantsOnly 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.
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}(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.
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.
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
)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.
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))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.
- 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.
This package is available under the terms of the MIT "Expat" License.