An OAuth 2.1 authorization server, configured per app via a single module.
The authorization server is a singleton — one per app, not one per user resource — so its config lives on its own module rather than on a strategy block of a user resource.
Usage
defmodule MyApp.Oauth2Server do
use AshAuthentication.Oauth2Server,
otp_app: :my_app,
user_resource: MyApp.Accounts.User,
issuer_url: {MyApp.Secrets, []},
resource_url: {MyApp.Secrets, []},
signing_secret: {MyApp.Secrets, []},
client_resource: MyApp.Accounts.OAuthClient,
authorization_code_resource: MyApp.Accounts.OAuthAuthorizationCode,
refresh_token_resource: MyApp.Accounts.OAuthRefreshToken,
consent_resource: MyApp.Accounts.OAuthConsent,
scopes: ["mcp"]
endRequired keys: :otp_app, :user_resource, :issuer_url, :resource_url,
:signing_secret, :client_resource, :authorization_code_resource,
:refresh_token_resource, :consent_resource.
Optional keys (with defaults):
| Key | Default | Notes |
|---|---|---|
:scopes | [] | Scope catalogue advertised in metadata and accepted at /authorize. Can be a static list (["read", "write"]), a 0-arity function (fn -> [...] end), or an MFA tuple ({Module, :function, [args]}) — use the function/MFA forms for dynamically-computed catalogues. The library default is empty, which combined with :enforce_scopes? (also default) means no scope works out of the box — the installer scaffolds a placeholder you're meant to replace. |
:enforce_scopes? | true | When true, requested scopes at /authorize MUST be a subset of :scopes. Set to false only if you have a dynamic / runtime-generated scope catalogue and intend to validate downstream. |
:access_token_lifetime | {1, :hour} | {integer, unit} where unit is :second, :minute, :hour, or :day |
:refresh_token_lifetime | {30, :days} | |
:authorization_code_lifetime | {10, :minutes} | |
:clock_skew_seconds | 30 | Tolerance applied to exp and nbf JWT claim checks. Allows for small clock differences between the AS and resource server. RFC 7519 §4.1.4 — "MAY provide for some small leeway, usually no more than a few minutes." |
:dcr_enabled? | false | Enable dynamic client registration (RFC 7591) at POST /oauth/register. Off by default — the safer posture for first-party-only apps. Turn on if you're hosting clients that self-register (MCP, ChatGPT Apps SDK, Claude.ai connectors). When off, the route 404s and the metadata document omits registration_endpoint. |
:dcr_always_return_client_secret? | false | Workaround for clients that misbehave when client_secret is absent for auth_method: none. See https://community.openai.com/t/1366118 |
:sign_in_path | nil | Path to redirect unauthenticated /oauth/authorize requests to. When nil, returns 401. |
:initial_access_token | nil | When set, POST /oauth/register requires the request to present a matching Authorization: Bearer … token (RFC 7591 §3). When nil (default), dynamic client registration is open — see the trust-model note below. |
Dynamic client registration
RFC 7591's POST /oauth/register endpoint is off by default —
the safer posture for first-party-only apps, where you have a fixed
set of clients and don't want a registration surface.
Turn it on (dcr_enabled?: true) when you're hosting an OAuth server
for clients that self-register: MCP servers (ChatGPT Apps SDK,
Claude.ai connectors, Claude Code, etc.) literally cannot work
without it — they fetch your discovery document, see the
registration_endpoint, and POST themselves into existence before
the user-facing flow can start. User-facing protection in that mode
lives further down in the consent screen and audience-bound tokens.
Even with DCR on, you can gate who can register by setting
:initial_access_token (RFC 7591 §3) and requiring the matching
Authorization: Bearer … header — useful when DCR exists for known
infrastructure rather than arbitrary internet clients.
Rate limiting
The protocol endpoints — /oauth/register, /oauth/token,
/oauth/revoke — are unauthenticated by design (clients haven't
finished authenticating yet) and so are reasonable DoS targets. RFC
7591 §5 explicitly notes that /register "MAY be rate-limited or
otherwise limited to prevent a denial-of-service attack on the
client registration endpoint."
We recommend implementing this at the router level rather than in the library — the right tool depends on your deployment (in-process per-node, Redis-backed across nodes, CDN/edge), and any plug you already use for the rest of your app will work here too. Some options:
Hammer— flexible counter backends (ETS, Redis, Mnesia).PlugAttack— composable throttling/blocking rules as a plug pipeline.- Edge/CDN-level limits (Cloudflare, Fastly, fly.io) — cheapest and stops bad traffic before it reaches your app.
If your app sits behind a reverse proxy or CDN, conn.remote_ip
defaults to the proxy's IP. Set up
remote_ip (or your own
X-Forwarded-For plug) so Phoenix sees the real client before any
IP-based limiter runs. For deployments where DCR doesn't need to be
open, you can turn the registration endpoint off entirely with
dcr_enabled?: false (the library default), or gate it behind a
shared secret with :initial_access_token.
Secret values
:issuer_url, :resource_url, :signing_secret, and
:initial_access_token accept any of:
- a literal string — resolved at compile time
- a
{Module, opts}tuple whereModuleimplementsAshAuthentication.Secret— resolved at call time - a 2-arity anonymous function — resolved at call time
- an MFA tuple
{Module, :function, [extra_args]}— resolved at call time
See AshAuthentication.Secret for details.
Reading the config
Each option is exposed as a function on the module:
iex> MyApp.Oauth2Server.user_resource()
MyApp.Accounts.User
iex> MyApp.Oauth2Server.issuer_url()
"https://app.example.com"
iex> MyApp.Oauth2Server.access_token_lifetime()
3600
Summary
Functions
Canonicalize a URL for redirect_uri / resource / issuer comparison.
Functions
Canonicalize a URL for redirect_uri / resource / issuer comparison.
Per RFC 8252 §7.3 and RFC 3986 §6 — lowercase scheme + host, elide default ports (80 for http, 443 for https), strip trailing slash off an empty path, drop the fragment. Two URLs that compare equal after this canonicalization are considered equivalent.