# SPDX-FileCopyrightText: 2026 ash_authentication_oauth2_server contributors <https://github.com/ash-project/ash_authentication_oauth2_server/graphs/contributors>
#
# SPDX-License-Identifier: MIT
# credo:disable-for-this-file Credo.Check.Design.AliasUsage
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.AshAuthenticationOauth2Server.Install do
use Igniter.Mix.Task
@example "mix ash_authentication_oauth2_server.install"
@shortdoc "Scaffolds an OAuth 2.1 authorization server"
@moduledoc """
#{@shortdoc}
Scaffolds:
* Four resources in the configured Ash domain — `OauthClient`,
`OauthAuthorizationCode`, `OauthRefreshToken`, `OauthConsent`.
* An `Oauth2Server` config module that pulls them together.
* Three `secret_for/4` clauses on the user's Secrets module
(`:issuer_url`, `:resource_url`, `:signing_secret`) that read from
application env, so prod overrides go in `config/runtime.exs`.
* Localhost defaults in `config/dev.exs` for development.
After install, run `mix ash.codegen --name add_oauth2_server` to
generate migrations for the new resources, then `mix ecto.migrate`.
The router macros are NOT auto-mounted. `use` the router module in
your Phoenix router and add the scopes by hand — different apps
want different paths/pipelines:
use AshAuthentication.Phoenix.Oauth2Server.Router
scope "/" do
pipe_through :browser
oauth2_server_consent_routes oauth2_server: MyApp.Oauth2Server
end
scope "/" do
pipe_through :api
oauth2_server_protocol_routes oauth2_server: MyApp.Oauth2Server
end
Then mount `AshAuthentication.Phoenix.Oauth2Server.BearerPlug` on
whatever resource you want OAuth-protected.
## Production config
The dev URLs written to `config/dev.exs` are placeholders. For prod,
set the real values in `config/runtime.exs`:
config :my_app,
oauth2_issuer_url: System.get_env("OAUTH2_ISSUER_URL"),
oauth2_resource_url: System.get_env("OAUTH2_RESOURCE_URL"),
oauth2_signing_secret: System.get_env("OAUTH2_SIGNING_SECRET")
`oauth2_resource_url` is the URL clients will reach your protected
resource at. It's bound to the access token's `aud` claim.
## Example
```bash
#{@example}
```
## Options
* `--accounts`, `-a` — Domain. Default: `MyApp.Accounts`.
* `--user`, `-u` — User resource. Default: `<Accounts>.User`.
* `--server-module`, `-s` — Where to put the `Oauth2Server` module.
Default: `MyApp.Oauth2Server`.
* `--secrets-module` — Module implementing `AshAuthentication.Secret`.
Default: `MyApp.Secrets`.
* `--issuer-url` — Issuer URL written to `config/dev.exs`.
Default: `http://localhost:4000`.
* `--resource-url` — Resource URL written to `config/dev.exs`.
Default: same as `--issuer-url`.
* `--scope` — Scope advertised in metadata. Default: `example.scope`
(a placeholder to replace with whatever your protected resource
actually uses).
"""
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :ash,
example: @example,
extra_args?: false,
only: nil,
positional: [],
schema: [
accounts: :string,
user: :string,
server_module: :string,
secrets_module: :string,
resource_url: :string,
issuer_url: :string,
scope: :string
],
aliases: [
a: :accounts,
u: :user,
s: :server_module
],
defaults: [
issuer_url: "http://localhost:4000",
scope: "mcp"
]
}
end
def igniter(igniter) do
options = parse_options(igniter)
case Igniter.Project.Module.module_exists(igniter, options[:user]) do
{true, igniter} ->
igniter
|> Igniter.Project.Formatter.import_dep(:ash_authentication_oauth2_server)
|> generate_resources(options)
|> add_resources_to_domain(options)
|> generate_server_module(options)
|> add_secrets(options)
|> add_app_config(options)
|> add_supervisor()
|> then(fn igniter ->
Igniter.add_notice(
igniter,
post_install_notice(options, Igniter.Project.Application.app_name(igniter))
)
end)
{false, igniter} ->
Igniter.add_issue(igniter, """
User module #{inspect(options[:user])} was not found.
Run `mix ash_authentication.install` first, then re-run this task.
""")
end
end
# ── option parsing ────────────────────────────────────────────────────
defp parse_options(igniter) do
app_module =
igniter
|> Igniter.Project.Module.module_name_prefix()
options =
igniter.args.options
|> Keyword.put_new_lazy(:accounts, fn ->
Igniter.Project.Module.module_name(igniter, "Accounts")
end)
options =
options
|> Keyword.put_new_lazy(:user, fn ->
Module.concat(options[:accounts], User)
end)
|> Keyword.put_new_lazy(:server_module, fn ->
Module.concat(app_module, Oauth2Server)
end)
|> Keyword.put_new_lazy(:secrets_module, fn ->
detect_secrets_module(igniter, options[:user]) ||
Module.concat(app_module, Secrets)
end)
|> Keyword.update!(:accounts, &AshAuthentication.Igniter.maybe_parse_module/1)
|> Keyword.update!(:user, &AshAuthentication.Igniter.maybe_parse_module/1)
|> Keyword.update!(:server_module, &AshAuthentication.Igniter.maybe_parse_module/1)
|> Keyword.update!(:secrets_module, &AshAuthentication.Igniter.maybe_parse_module/1)
options
end
defp detect_secrets_module(_igniter, _user) do
# Best-effort: peek at the user resource's compile-time config. If we
# can't find it cleanly, the caller falls back to <App>.Secrets which
# is the convention `mix ash_authentication.install` produces.
nil
end
# ── resource generation ───────────────────────────────────────────────
defp generate_resources(igniter, options) do
ns = parent_namespace(options[:user])
igniter
|> generate_client_resource(Module.concat(ns, OauthClient), options)
|> generate_authorization_code_resource(
Module.concat(ns, OauthAuthorizationCode),
options
)
|> generate_refresh_token_resource(Module.concat(ns, OauthRefreshToken), options)
|> generate_consent_resource(Module.concat(ns, OauthConsent), options)
end
defp generate_client_resource(igniter, mod, _options) do
{exists?, igniter} = Igniter.Project.Module.module_exists(igniter, mod)
if exists?, do: igniter, else: do_generate_client(igniter, mod)
end
defp do_generate_client(igniter, mod) do
igniter
|> Igniter.compose_task("ash.gen.resource", [
inspect(mod),
"--uuid-v7-primary-key",
"id",
"--default-actions",
"read,destroy",
"--attribute",
"client_name:string:required:public",
"--attribute",
"redirect_uris:string:array:required:public",
"--attribute",
"grant_types:string:array:public",
"--attribute",
"response_types:string:array:public",
"--attribute",
"token_endpoint_auth_method:string:public",
"--attribute",
"scope:string:public",
"--attribute",
"last_used_at:utc_datetime_usec:public",
"--timestamps",
"--extend",
data_layer_extension()
])
|> Ash.Resource.Igniter.add_new_action(mod, :register, """
create :register do
accept [:client_name, :redirect_uris, :grant_types, :response_types, :token_endpoint_auth_method, :scope]
end
""")
|> Ash.Resource.Igniter.add_new_action(mod, :touch, """
update :touch do
accept []
change atomic_update(:last_used_at, expr(now()))
end
""")
|> add_authn_bypass(mod)
end
defp generate_authorization_code_resource(igniter, mod, options) do
{exists?, igniter} = Igniter.Project.Module.module_exists(igniter, mod)
if exists?, do: igniter, else: do_generate_auth_code(igniter, mod, options)
end
defp do_generate_auth_code(igniter, mod, options) do
igniter
|> Igniter.compose_task("ash.gen.resource", [
inspect(mod),
"--uuid-v7-primary-key",
"id",
"--default-actions",
"read,destroy",
"--attribute",
"client_id:uuid_v7:required:public",
"--attribute",
"user_id:#{user_id_type(options)}:required:public",
"--attribute",
"redirect_uri:string:required:public",
"--attribute",
"code_challenge:string:required:public",
"--attribute",
"scope:string:required:public",
"--attribute",
"resource_uri:string:required:public",
"--attribute",
"expires_at:utc_datetime_usec:required:public",
"--attribute",
"consumed_at:utc_datetime_usec:public",
"--extend",
data_layer_extension()
])
|> Ash.Resource.Igniter.add_new_action(mod, :create, """
create :create do
accept [:client_id, :user_id, :redirect_uri, :code_challenge, :scope, :resource_uri, :expires_at]
end
""")
|> Ash.Resource.Igniter.add_new_action(mod, :consume, """
update :consume do
accept []
validate absent(:consumed_at) do
message "code already used"
end
change atomic_update(:consumed_at, expr(now()))
end
""")
|> Igniter.compose_task("ash.extend", [
inspect(mod),
"AshAuthentication.Oauth2Server.AuthorizationCodeResource"
])
|> add_authn_bypass(mod)
end
defp generate_refresh_token_resource(igniter, mod, options) do
{exists?, igniter} = Igniter.Project.Module.module_exists(igniter, mod)
if exists?, do: igniter, else: do_generate_refresh(igniter, mod, options)
end
defp do_generate_refresh(igniter, mod, options) do
igniter
|> Igniter.compose_task("ash.gen.resource", [
inspect(mod),
"--default-actions",
"read,destroy",
"--attribute",
"token_hash:string:required:public",
"--attribute",
"client_id:uuid_v7:required:public",
"--attribute",
"user_id:#{user_id_type(options)}:required:public",
"--attribute",
"scope:string:required:public",
"--attribute",
"resource_uri:string:required:public",
"--attribute",
"expires_at:utc_datetime_usec:required:public",
"--attribute",
"chain_id:uuid_v7:required:public",
"--attribute",
"rotated_to_id:uuid_v7:public",
"--attribute",
"rotated_at:utc_datetime_usec:public",
"--attribute",
"revoked_at:utc_datetime_usec:public",
"--extend",
data_layer_extension()
])
# The library pre-allocates the new refresh row's `id` so the
# `:rotate` action can atomically set `rotated_to_id = ^new_id` in
# one filtered UPDATE. That requires the primary key to be
# writable, which `uuid_v7_primary_key` doesn't default to.
|> Ash.Resource.Igniter.add_new_attribute(mod, :id, """
attribute :id, :uuid_v7 do
primary_key? true
allow_nil? false
default &Ash.UUIDv7.generate/0
writable? true
public? true
end
""")
# Generation counter: 0 at initial issue, incremented by 1 on
# every rotation. The library doesn't enforce a max — surface it
# for end users to enforce if they want.
|> Ash.Resource.Igniter.add_new_attribute(mod, :generation, """
attribute :generation, :integer do
allow_nil? false
default 0
public? true
end
""")
|> Ash.Resource.Igniter.add_new_action(mod, :issue, """
create :issue do
accept [:id, :chain_id, :generation, :token_hash, :client_id, :user_id, :scope, :resource_uri, :expires_at]
end
""")
# The change attaches the atomic filter + sets rotated_to_id. The
# RefreshTokenResource verifier checks for its presence so the
# race-safety contract can't silently be broken by editing the action.
|> Ash.Resource.Igniter.add_new_action(mod, :rotate, """
update :rotate do
argument :rotated_to_id, :uuid_v7, allow_nil?: false
accept []
change AshAuthentication.Oauth2Server.Changes.RotateRefreshToken
end
""")
|> Ash.Resource.Igniter.add_new_action(mod, :revoke, """
update :revoke do
accept []
change atomic_update(:revoked_at, expr(now()))
end
""")
|> Ash.Resource.Igniter.add_new_identity(mod, :by_token_hash, """
identity :by_token_hash, [:token_hash]
""")
# Compile-time check that the resource still meets the race-safety
# contract (writable :id, :rotate action carrying the rotation
# change module).
|> Igniter.compose_task("ash.extend", [
inspect(mod),
"AshAuthentication.Oauth2Server.RefreshTokenResource"
])
|> add_authn_bypass(mod)
end
defp generate_consent_resource(igniter, mod, options) do
{exists?, igniter} = Igniter.Project.Module.module_exists(igniter, mod)
if exists?, do: igniter, else: do_generate_consent(igniter, mod, options)
end
defp do_generate_consent(igniter, mod, options) do
igniter
|> Igniter.compose_task("ash.gen.resource", [
inspect(mod),
"--uuid-v7-primary-key",
"id",
"--default-actions",
"read,destroy",
"--attribute",
"user_id:#{user_id_type(options)}:required:public",
"--attribute",
"client_id:uuid_v7:required:public",
"--attribute",
"scope:string:required:public",
"--attribute",
"granted_at:utc_datetime_usec:required:public",
"--extend",
data_layer_extension()
])
|> Ash.Resource.Igniter.add_new_action(mod, :grant, """
create :grant do
upsert? true
upsert_identity :by_user_client
accept [:user_id, :client_id, :scope]
change set_attribute(:granted_at, &DateTime.utc_now/0)
end
""")
|> Ash.Resource.Igniter.add_new_identity(mod, :by_user_client, """
identity :by_user_client, [:user_id, :client_id]
""")
|> add_authn_bypass(mod)
end
# ── domain wiring ────────────────────────────────────────────────────
defp add_resources_to_domain(igniter, options) do
ns = parent_namespace(options[:user])
[
Module.concat(ns, OauthClient),
Module.concat(ns, OauthAuthorizationCode),
Module.concat(ns, OauthRefreshToken),
Module.concat(ns, OauthConsent)
]
|> Enum.reduce(igniter, fn resource, igniter ->
Ash.Domain.Igniter.add_resource_reference(igniter, options[:accounts], resource)
end)
end
# ── Oauth2Server config module ───────────────────────────────────────
defp generate_server_module(igniter, options) do
ns = parent_namespace(options[:user])
otp_app = Igniter.Project.Application.app_name(igniter)
contents = """
@moduledoc \"\"\"
OAuth 2.1 authorization-server configuration.
See `AshAuthentication.Oauth2Server` for all options.
\"\"\"
use AshAuthentication.Oauth2Server,
otp_app: #{inspect(otp_app)},
user_resource: #{inspect(options[:user])},
issuer_url: {#{inspect(options[:secrets_module])}, []},
resource_url: {#{inspect(options[:secrets_module])}, []},
signing_secret: {#{inspect(options[:secrets_module])}, []},
client_resource: #{inspect(Module.concat(ns, OauthClient))},
authorization_code_resource: #{inspect(Module.concat(ns, OauthAuthorizationCode))},
refresh_token_resource: #{inspect(Module.concat(ns, OauthRefreshToken))},
consent_resource: #{inspect(Module.concat(ns, OauthConsent))},
scopes: [#{inspect(options[:scope])}],
# Dynamic client registration (RFC 7591). The library default is
# `false` for safety; the installer turns it on because most
# people setting up an OAuth server today need it for MCP-style
# flows (ChatGPT Apps SDK, Claude.ai connectors, etc.). Set to
# `false` if your auth server is for a fixed set of first-party
# clients only.
dcr_enabled?: true,
sign_in_path: "/sign-in"
"""
Igniter.Project.Module.create_module(igniter, options[:server_module], contents,
on_exists: :skip
)
end
# ── Secrets module ───────────────────────────────────────────────────
defp add_secrets(igniter, options) do
[
{[:issuer_url], :oauth2_issuer_url},
{[:resource_url], :oauth2_resource_url},
{[:signing_secret], :oauth2_signing_secret}
]
|> Enum.reduce(igniter, fn {path, env_key}, igniter ->
# The secret_for/4 callback's second arg matches whatever
# `Oauth2Server.__resolve_secret__!/3` passes — the server module.
AshAuthentication.Igniter.add_new_secret_from_env(
igniter,
options[:secrets_module],
options[:server_module],
path,
env_key
)
end)
end
# ── application config ───────────────────────────────────────────────
defp add_app_config(igniter, options) do
otp_app = Igniter.Project.Application.app_name(igniter)
signing_secret = generate_signing_secret()
resource_url = options[:resource_url] || options[:issuer_url]
igniter
|> Igniter.Project.Config.configure(
"dev.exs",
otp_app,
[:oauth2_issuer_url],
options[:issuer_url]
)
|> Igniter.Project.Config.configure(
"dev.exs",
otp_app,
[:oauth2_resource_url],
resource_url
)
|> Igniter.Project.Config.configure(
"dev.exs",
otp_app,
[:oauth2_signing_secret],
signing_secret
)
end
defp generate_signing_secret do
:crypto.strong_rand_bytes(32) |> Base.encode64(padding: false)
end
# Add `Ash.Policy.Authorizer` to the resource and emit a single bypass
# for `AshAuthentication.Checks.AshAuthenticationInteraction`. Other
# callers can layer their own policies on top later.
defp add_authn_bypass(igniter, resource) do
igniter
|> Igniter.compose_task("ash.extend", [inspect(resource), "Ash.Policy.Authorizer"])
|> Ash.Resource.Igniter.add_bypass(
resource,
quote do
AshAuthentication.Checks.AshAuthenticationInteraction
end,
quote do
authorize_if always()
end
)
end
# ── helpers ──────────────────────────────────────────────────────────
defp add_supervisor(igniter) do
otp_app = Igniter.Project.Application.app_name(igniter)
Igniter.Project.Application.add_new_child(
igniter,
{AshAuthentication.Oauth2Server.Supervisor, otp_app: otp_app}
)
end
defp parent_namespace(module) do
module
|> Module.split()
|> :lists.droplast()
|> Module.concat()
end
defp user_id_type(options) do
# Default to UUID v4 (matches `uuid_primary_key :id` from the
# standard ash_authentication.install). Users with uuid_v7
# user resources can pass --user-id-type uuid_v7 in a future
# extension, but for now `:uuid` matches the install default.
_ = options
"uuid"
end
defp data_layer_extension do
cond do
Code.ensure_loaded?(AshPostgres.DataLayer) -> "postgres"
Code.ensure_loaded?(AshSqlite.DataLayer) -> "sqlite"
true -> ""
end
end
defp post_install_notice(options, otp_app) do
"""
OAuth 2.1 server scaffolded.
1. Wire the OAuth routes into your Phoenix router. Add a
`use AshAuthentication.Phoenix.Oauth2Server.Router` near the
top of `MyAppWeb.Router`, then mount the scopes in your
existing `:browser` and `:api` pipelines:
scope "/" do
pipe_through :browser
oauth2_server_consent_routes oauth2_server: #{inspect(options[:server_module])}
end
scope "/" do
pipe_through :api
oauth2_server_protocol_routes oauth2_server: #{inspect(options[:server_module])}
end
2. Make sure your `:browser` pipeline sets the actor. The consent
endpoint uses `Ash.PlugHelpers.get_actor/1` to figure out who's
consenting, so the pipeline needs the standard pair:
plug :load_from_session
plug :set_actor, :user
If the actor isn't set, signed-in users will get bounced through
the sign-in flow as if they weren't logged in.
3. Mount the bearer plug on whatever resource(s) you want
OAuth-protected (an API, MCP endpoint, admin tool, etc.):
plug AshAuthentication.Phoenix.Oauth2Server.BearerPlug,
oauth2_server: #{inspect(options[:server_module])}
4. Run `mix ecto.migrate` to apply the new tables.
5. The protocol endpoints (`/oauth/register`, `/oauth/token`,
`/oauth/revoke`) are unauthenticated by design and so are
reasonable DoS targets. See the "Rate limiting" section of
`AshAuthentication.Oauth2Server` for a plug-level snippet.
6. For production, set real values in `config/runtime.exs`:
config :#{otp_app},
oauth2_issuer_url: System.get_env("OAUTH2_ISSUER_URL"),
oauth2_resource_url: System.get_env("OAUTH2_RESOURCE_URL"),
oauth2_signing_secret: System.get_env("OAUTH2_SIGNING_SECRET")
"""
end
end
else
defmodule Mix.Tasks.AshAuthenticationOauth2Server.Install do
@shortdoc "Scaffolds an OAuth 2.1 authorization server"
@moduledoc @shortdoc
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_authentication.add_oauth2_server' requires igniter to be run.
Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter
""")
exit({:shutdown, 1})
end
end
end