# `AshAuthentication.Oauth2Server`
[🔗](https://github.com/team-alembic/ash_authentication_oauth2_server/blob/v0.1.0/lib/ash_authentication/oauth2_server.ex#L5)

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

# `__normalize_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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
