AshAuthentication.Oauth2Server (ash_authentication_oauth2_server v0.1.0)

Copy Markdown View Source

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"]
end

Required 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):

KeyDefaultNotes
: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?trueWhen 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_seconds30Tolerance 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?falseEnable 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?falseWorkaround for clients that misbehave when client_secret is absent for auth_method: none. See https://community.openai.com/t/1366118
:sign_in_pathnilPath to redirect unauthenticated /oauth/authorize requests to. When nil, returns 401.
:initial_access_tokennilWhen 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 where Module implements AshAuthentication.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

__normalize_url__(url)

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.