# SPDX-FileCopyrightText: 2026 ash_authentication_oauth2_server contributors <https://github.com/ash-project/ash_authentication_oauth2_server/graphs/contributors>
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Oauth2Server do
@moduledoc """
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
```elixir
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):
| 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`](https://hex.pm/packages/hammer) — flexible counter
backends (ETS, Redis, Mnesia).
* [`PlugAttack`](https://hex.pm/packages/plug_attack) — 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`](https://hexdocs.pm/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
"""
alias AshAuthentication.Oauth2Server
@required_keys [
:otp_app,
:user_resource,
:issuer_url,
:resource_url,
:signing_secret,
:client_resource,
:authorization_code_resource,
:refresh_token_resource,
:consent_resource
]
@doc false
def __default_opts__ do
[
scopes: [],
enforce_scopes?: true,
access_token_lifetime: {1, :hour},
refresh_token_lifetime: {30, :days},
authorization_code_lifetime: {10, :minutes},
clock_skew_seconds: 30,
dcr_enabled?: false,
dcr_always_return_client_secret?: false,
sign_in_path: nil,
initial_access_token: nil
]
end
@doc false
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
Oauth2Server.__validate_opts__!(__MODULE__, opts)
@oauth2_server_opts Keyword.merge(Oauth2Server.__default_opts__(), opts)
def otp_app, do: Keyword.fetch!(@oauth2_server_opts, :otp_app)
def user_resource, do: Keyword.fetch!(@oauth2_server_opts, :user_resource)
def client_resource, do: Keyword.fetch!(@oauth2_server_opts, :client_resource)
def authorization_code_resource,
do: Keyword.fetch!(@oauth2_server_opts, :authorization_code_resource)
def refresh_token_resource,
do: Keyword.fetch!(@oauth2_server_opts, :refresh_token_resource)
def consent_resource, do: Keyword.fetch!(@oauth2_server_opts, :consent_resource)
def scopes do
@oauth2_server_opts
|> Keyword.fetch!(:scopes)
|> Oauth2Server.__resolve_scopes__!(__MODULE__)
end
def enforce_scopes?, do: Keyword.fetch!(@oauth2_server_opts, :enforce_scopes?)
def clock_skew_seconds, do: Keyword.fetch!(@oauth2_server_opts, :clock_skew_seconds)
def sign_in_path, do: Keyword.fetch!(@oauth2_server_opts, :sign_in_path)
def dcr_enabled?, do: Keyword.fetch!(@oauth2_server_opts, :dcr_enabled?)
def dcr_always_return_client_secret?,
do: Keyword.fetch!(@oauth2_server_opts, :dcr_always_return_client_secret?)
def access_token_lifetime,
do: Oauth2Server.__lifetime_seconds__(@oauth2_server_opts[:access_token_lifetime])
def refresh_token_lifetime,
do: Oauth2Server.__lifetime_seconds__(@oauth2_server_opts[:refresh_token_lifetime])
def authorization_code_lifetime,
do: Oauth2Server.__lifetime_seconds__(@oauth2_server_opts[:authorization_code_lifetime])
def issuer_url(context \\ %{}) do
@oauth2_server_opts
|> Keyword.fetch!(:issuer_url)
|> Oauth2Server.__resolve_secret__!(__MODULE__, [:issuer_url], context)
|> Oauth2Server.__normalize_url__()
end
def resource_url(context \\ %{}) do
@oauth2_server_opts
|> Keyword.fetch!(:resource_url)
|> Oauth2Server.__resolve_secret__!(__MODULE__, [:resource_url], context)
|> Oauth2Server.__normalize_url__()
end
def signing_secret(context \\ %{}) do
@oauth2_server_opts
|> Keyword.fetch!(:signing_secret)
|> Oauth2Server.__resolve_secret__!(__MODULE__, [:signing_secret], context)
end
@doc """
The configured initial access token, or `nil` if dynamic client
registration is open.
When non-nil, `POST /oauth/register` requires the request to present
the matching token in `Authorization: Bearer …`. See RFC 7591 §3.
"""
def initial_access_token do
case @oauth2_server_opts[:initial_access_token] do
nil ->
nil
spec ->
Oauth2Server.__resolve_secret__!(spec, __MODULE__, [:initial_access_token])
end
end
def __oauth2_server__, do: true
end
end
@doc false
def __validate_opts__!(module, opts) do
missing = @required_keys -- Keyword.keys(opts)
if missing != [] do
raise CompileError,
description:
"#{inspect(module)} is missing required `use AshAuthentication.Oauth2Server` options: " <>
inspect(missing)
end
case Keyword.fetch!(opts, :otp_app) do
atom when is_atom(atom) ->
:ok
other ->
raise CompileError,
description: "expected `:otp_app` to be an atom, got: #{inspect(other)}"
end
Enum.each(
[
:user_resource,
:client_resource,
:authorization_code_resource,
:refresh_token_resource,
:consent_resource
],
fn key ->
case Keyword.fetch!(opts, key) do
atom when is_atom(atom) and not is_nil(atom) ->
:ok
other ->
raise CompileError,
description: "expected `#{inspect(key)}` to be a module, got: #{inspect(other)}"
end
end
)
:ok
end
@doc false
@lifetime_units %{
second: 1,
seconds: 1,
minute: 60,
minutes: 60,
hour: 3_600,
hours: 3_600,
day: 86_400,
days: 86_400
}
def __lifetime_seconds__(seconds) when is_integer(seconds) and seconds > 0, do: seconds
def __lifetime_seconds__({n, unit}) when is_integer(n) and n > 0 do
multiplier = Map.fetch!(@lifetime_units, unit)
n * multiplier
end
def __lifetime_seconds__(other),
do: raise(ArgumentError, "invalid lifetime: #{inspect(other)}")
@doc false
def __resolve_secret__!(value, module, path, context \\ %{}) do
case resolve_secret(value, module, path, context) do
{:ok, resolved} ->
resolved
:error ->
raise "Oauth2Server: failed to resolve secret at #{inspect(path)} on #{inspect(module)}"
{:error, reason} ->
raise "Oauth2Server: failed to resolve secret at #{inspect(path)}: #{inspect(reason)}"
end
end
defp resolve_secret(value, _module, _path, _context) when is_binary(value), do: {:ok, value}
defp resolve_secret({mod, opts}, module, path, context)
when is_atom(mod) and is_list(opts) do
Code.ensure_loaded(mod)
if function_exported?(mod, :__secret_for_arity__, 0) do
AshAuthentication.Secret.secret_for(mod, path, module, opts, context)
else
{:error, {:not_a_secret_module, mod}}
end
end
defp resolve_secret({mod, fun, args}, module, path, _context)
when is_atom(mod) and is_atom(fun) and is_list(args) do
case apply(mod, fun, [path, module | args]) do
{:ok, value} -> {:ok, value}
:error -> :error
other -> {:ok, other}
end
end
defp resolve_secret(fun, module, path, _context) when is_function(fun, 2) do
case fun.(path, module) do
{:ok, value} -> {:ok, value}
:error -> :error
other -> {:ok, other}
end
end
defp resolve_secret(other, _module, _path, _context),
do: {:error, {:invalid_secret, other}}
@doc false
# Resolve the `:scopes` option, which may be a static list, a 0-arity
# function, or an MFA tuple. Returns the list of scope strings.
def __resolve_scopes__!(list, _module) when is_list(list), do: list
def __resolve_scopes__!(fun, _module) when is_function(fun, 0),
do: ensure_scopes_list!(fun.(), fun)
def __resolve_scopes__!({mod, fun, args} = mfa, _module)
when is_atom(mod) and is_atom(fun) and is_list(args),
do: ensure_scopes_list!(apply(mod, fun, args), mfa)
def __resolve_scopes__!(other, module) do
raise """
Invalid `:scopes` value on #{inspect(module)}: #{inspect(other)}.
Expected one of:
* a list of scope strings — `["read", "write"]`
* a 0-arity function — `fn -> ["read", "write"] end`
* an MFA tuple — `{Module, :function, [args]}`
"""
end
defp ensure_scopes_list!(list, _source) when is_list(list), do: list
defp ensure_scopes_list!(other, source),
do: raise("#{inspect(source)} returned #{inspect(other)}, expected a list of scopes")
@doc """
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.
"""
def __normalize_url__(url) when is_binary(url) do
uri = URI.parse(url)
scheme = uri.scheme && String.downcase(uri.scheme)
%{
uri
| scheme: scheme,
host: uri.host && String.downcase(uri.host),
port: normalize_port(scheme, uri.port),
path: normalize_path(uri.path),
fragment: nil
}
|> URI.to_string()
|> String.trim_trailing("/")
end
defp normalize_path(nil), do: nil
defp normalize_path("/"), do: nil
defp normalize_path(path), do: path
defp normalize_port("http", 80), do: nil
defp normalize_port("https", 443), do: nil
defp normalize_port(_, port), do: port
end