# 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.Authorize do
@moduledoc """
Protocol-pure logic for the `/oauth/authorize` endpoint.
Controllers in `ash_authentication_phoenix` are thin wrappers around
`validate_request/3`, `consented?/5`, `grant_consent!/5`, and
`issue_code!/4`. None of these functions touch `Plug.Conn`.
## Authorization & tenancy
All Ash calls run through the `AshAuthentication.Checks.AshAuthenticationInteraction`
bypass (set by the installer) rather than `authorize?: false`. Every public
function accepts an `opts` keyword that may include `:tenant`; when set, it's
threaded to every action so multi-tenant resources scope correctly.
"""
require Ash.Query
alias AshAuthentication.Oauth2Server
@ash_context %{private: %{ash_authentication?: true}}
@typedoc """
The validated authorize-request payload. The struct is intentionally small —
enough to render a consent screen and ultimately mint an authorization code.
"""
@type validated :: %{
client: Ash.Resource.record(),
redirect_uri: String.t(),
code_challenge: String.t(),
scope: String.t(),
state: String.t(),
resource: String.t()
}
@typedoc "Options shared across this module's public functions."
@type opts :: [tenant: any()]
@doc """
Validate an inbound authorize request.
Returns:
* `{:ok, validated}` — request is structurally sound and the client +
redirect_uri are known.
* `{:error, :bad_redirect_uri}` — redirect_uri is missing or doesn't
match a registered URI; per RFC 6749 §4.1.2.1 the controller MUST NOT
redirect.
* `{:error, error_code, description}` — any other validation error.
Controllers redirect these errors back to `redirect_uri`.
## A note on the `state` parameter
Clients MUST set `state` to a cryptographically random, unguessable
value (RFC 6749 §10.12 / RFC 9700 §4.7). The server echoes it back via
the redirect so the client can correlate the response with its
pending request — and verify the response didn't come from a CSRF or
injection attack.
This means **clients should NOT use `state` as a stash for
application-level data** like a "return-to" URL or routing hints. That
pattern is unsafe — the value travels through the user-agent and
query string and is reflected back by the server, so any data put in
it can be observed, replayed, or tampered with. Stash that data
server-side (keyed by a fresh `state`), or encode it in a signed
cookie.
We don't enforce a shape or entropy minimum here, but anything other
than a random per-request value defeats the purpose of `state`.
"""
@spec validate_request(server :: module(), params :: map(), opts()) ::
{:ok, validated()}
| {:error, :bad_redirect_uri}
| {:error, String.t(), String.t()}
def validate_request(server, params, opts \\ []) do
secret_context = secret_context(Keyword.get(opts, :tenant))
with :ok <- require_eq(params, "response_type", "code", "unsupported_response_type"),
{:ok, client} <- load_client(server, params, opts),
:ok <- check_redirect_uri(params, client),
:ok <- require_eq(params, "code_challenge_method", "S256", "invalid_request"),
{:ok, resource} <- resolve_resource(server, params, secret_context),
{:ok, code_challenge} <- require_present(params, "code_challenge"),
{:ok, scope} <- require_present(params, "scope"),
:ok <- check_scopes(server, scope),
{:ok, redirect_uri} <- require_present(params, "redirect_uri"),
{:ok, state} <- require_present(params, "state") do
{:ok,
%{
client: client,
redirect_uri: redirect_uri,
code_challenge: code_challenge,
scope: scope,
state: state,
resource: resource
}}
end
end
@doc """
Has the user already consented to this client at a scope that covers the
currently-requested scope?
Returns true ONLY when prior consent exists AND its scope is a superset of
`requested_scope`. This prevents silent privilege expansion when a client
later asks for more scopes than the user originally agreed to.
"""
@spec consented?(
server :: module(),
user :: Ash.Resource.record(),
client :: Ash.Resource.record(),
requested_scope :: String.t(),
opts()
) :: boolean()
def consented?(server, user, client, requested_scope, opts \\ []) do
server.consent_resource()
|> Ash.Query.filter(user_id == ^user.id and client_id == ^client.id)
|> Ash.read_one(ash_opts(opts))
|> case do
{:ok, %{scope: stored}} -> scope_covers?(stored, requested_scope)
_ -> false
end
end
@doc """
Record (or refresh) a consent row for `(user, client)` at the given scope.
"""
@spec grant_consent!(
server :: module(),
user :: Ash.Resource.record(),
client :: Ash.Resource.record(),
scope :: String.t(),
opts()
) :: Ash.Resource.record()
def grant_consent!(server, user, client, scope, opts \\ []) do
server.consent_resource()
|> Ash.Changeset.for_create(:grant, %{
user_id: user.id,
client_id: client.id,
scope: scope
})
|> Ash.create!(ash_opts(opts))
end
@doc """
Mint a new short-lived authorization code bound to the user, client, scope,
PKCE challenge, and resource URI.
"""
@spec issue_code!(
server :: module(),
user :: Ash.Resource.record(),
validated :: validated(),
opts()
) :: Ash.Resource.record()
def issue_code!(server, user, validated, opts \\ []) do
expires_at =
DateTime.add(DateTime.utc_now(), server.authorization_code_lifetime(), :second)
server.authorization_code_resource()
|> Ash.Changeset.for_create(:create, %{
client_id: validated.client.id,
user_id: user.id,
redirect_uri: validated.redirect_uri,
code_challenge: validated.code_challenge,
scope: validated.scope,
resource_uri: validated.resource,
expires_at: expires_at
})
|> Ash.create!(ash_opts(opts))
end
# ── helpers ──────────────────────────────────────────────────────────────
# Builds the standard Ash opts: bypass context + tenant if provided.
defp ash_opts(opts) do
base = [context: @ash_context]
case Keyword.get(opts, :tenant) do
nil -> base
tenant -> Keyword.put(base, :tenant, tenant)
end
end
defp require_eq(params, key, expected, error_code) do
case Map.get(params, key) do
^expected -> :ok
_ -> {:error, error_code, "expected #{key}=#{expected}"}
end
end
defp require_present(params, key) do
case Map.get(params, key) do
v when is_binary(v) and v != "" -> {:ok, v}
_ -> {:error, "invalid_request", "#{key} is required"}
end
end
defp load_client(server, %{"client_id" => id}, opts) do
case Ash.get(server.client_resource(), id, ash_opts(opts)) do
{:ok, client} -> {:ok, client}
_ -> {:error, "invalid_client", "unknown client_id"}
end
end
defp load_client(_server, _params, _opts),
do: {:error, "invalid_request", "client_id required"}
# RFC 9700 §4.1 — exact byte-equal match. No normalization, no
# default-port elision, no trailing-slash equivalence. The client MUST
# use the same redirect URI string it registered with.
defp check_redirect_uri(%{"redirect_uri" => uri}, %{redirect_uris: uris})
when is_binary(uri) and is_list(uris) do
if uri in uris, do: :ok, else: {:error, :bad_redirect_uri}
end
defp check_redirect_uri(_, _), do: {:error, :bad_redirect_uri}
# When `enforce_scopes?` is true (the default), every requested scope
# must be in the server's advertised catalogue. When false, scopes are
# passed through unchecked — for apps with a dynamic catalogue that
# validate scopes downstream.
defp check_scopes(server, scope) when is_binary(scope) do
if server.enforce_scopes?() do
allowed = MapSet.new(server.scopes())
requested = scope |> String.split(" ", trim: true) |> MapSet.new()
case MapSet.difference(requested, allowed) |> MapSet.to_list() do
[] -> :ok
[unknown | _] -> {:error, "invalid_scope", "scope #{inspect(unknown)} not allowed"}
end
else
:ok
end
end
# `resource` is optional per RFC 8707 §2 — when absent, default to the
# server's configured resource_url. When present, it MUST match.
# We echo the *expected* URL (server-controlled) in the error description
# rather than the user-supplied value, so the message is useful without
# creating a "reflect user input" surface.
defp resolve_resource(server, %{"resource" => res}, secret_context)
when is_binary(res) and res != "" do
expected = server.resource_url(secret_context)
if Oauth2Server.__normalize_url__(res) == expected,
do: {:ok, expected},
else:
{:error, "invalid_target",
"resource parameter does not match this authorization server " <>
"(expected: #{expected})"}
end
defp resolve_resource(server, _, secret_context),
do: {:ok, server.resource_url(secret_context)}
defp secret_context(nil), do: %{}
defp secret_context(tenant), do: %{tenant: tenant}
defp scope_covers?(stored, requested) when is_binary(stored) and is_binary(requested) do
stored_set = stored |> String.split(" ", trim: true) |> MapSet.new()
requested_set = requested |> String.split(" ", trim: true) |> MapSet.new()
MapSet.subset?(requested_set, stored_set)
end
defp scope_covers?(_, _), do: false
end