Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OAuth.jl

CI Stable Docs Dev Docs Coverage

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-known documents.
  • First-class support for sender-constrained tokens (DPoP), resource indicators, and authorization_details objects 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.

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.

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.

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

  1. Install Julia 1.10 or newer.
  2. Run the test suite with julia --project -e 'using Pkg; Pkg.test()'.
  3. 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.

About

Pure Julia implementation of OAuth

Topics

Resources

Stars

24 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages