Skip to main content

lib/ash_authentication/oauth2_server/authorize.ex

# 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