Skip to main content

lib/ash_authentication_phoenix/oauth2_server/consent_router.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.Phoenix.Oauth2Server.ConsentRouter do
  @moduledoc """
  Plug router for the human-driven consent step of the OAuth 2.1 flow.

  Handles `GET /authorize` (renders the consent screen) and
  `POST /authorize` (records the consent decision and redirects with a code).
  Both require a logged-in browser session — mount this behind your
  browser/session pipeline, not your API pipeline.

  See `AshAuthentication.Phoenix.Oauth2Server.ProtocolRouter` for the
  client-facing protocol endpoints (token, register, metadata).

  ## Options

    * `:oauth2_server` (required) — the user's `Oauth2Server` config module
    * `:consent_view` — module exposing `render(:consent, assigns)`
      (default: `AshAuthentication.Phoenix.Oauth2Server.ConsentView`)
  """

  use Plug.Router, copy_opts_to_assign: :oauth2_server_router_opts

  alias AshAuthentication.Oauth2Server
  alias AshAuthentication.Oauth2Server.Authorize
  alias AshAuthentication.Phoenix.Oauth2Server.{ConsentView, Errors}

  @max_state_bytes 2048
  @consent_request_salt "ash_authentication_phoenix oauth2_server consent_request v1"
  @consent_request_max_age 600

  plug Plug.Parsers,
    parsers: [:urlencoded],
    pass: ["*/*"]

  plug :match
  plug :dispatch

  get "/" do
    opts = conn.assigns.oauth2_server_router_opts
    handle_get(conn, server!(opts), opts)
  end

  post "/" do
    opts = conn.assigns.oauth2_server_router_opts
    handle_post(conn, server!(opts))
  end

  match _ do
    conn |> send_resp(404, "") |> halt()
  end

  # ── handlers ──────────────────────────────────────────────────────────────

  defp handle_get(conn, server, opts) do
    conn = fetch_query_params(conn)
    params = conn.query_params
    tenant_opts = tenant_opts(conn)

    with :ok <- check_state_size(params),
         {:ok, validated} <- Authorize.validate_request(server, params, tenant_opts),
         {:ok, user} <- require_user(conn) do
      if Authorize.consented?(server, user, validated.client, validated.scope, tenant_opts) do
        issue_code_redirect(conn, server, user, validated)
      else
        render_consent(conn, validated, opts)
      end
    else
      {:error, :no_user} -> sign_in_redirect(conn, server)
      {:error, :bad_redirect_uri} -> bad_redirect_html(conn)
      {:error, :state_too_large} -> bad_state_html(conn)
      {:error, code, desc} -> handle_authorize_error(conn, server, params, code, desc)
    end
  end

  # The POST request fields are reconstructed from a sealed `consent_request`
  # token that was minted server-side at GET time. The form's own scope /
  # code_challenge / redirect_uri values are intentionally ignored — that
  # binds the user-visible consent UI to what we actually act on.
  defp handle_post(conn, server) do
    raw_params = conn.params

    case verify_consent_request(server, Map.get(raw_params, "consent_request")) do
      {:ok, sealed} ->
        handle_post_authorized(conn, server, raw_params, sealed_params(sealed))

      {:error, _} ->
        Errors.send_oauth_error(
          conn,
          400,
          "invalid_request",
          "consent request token missing, invalid, or expired"
        )
    end
  end

  defp handle_post_authorized(conn, server, raw_params, params) do
    tenant_opts = tenant_opts(conn)

    with {:ok, validated} <- Authorize.validate_request(server, params, tenant_opts),
         {:ok, user} <- require_user(conn) do
      case Map.get(raw_params, "action") do
        "approve" ->
          Authorize.grant_consent!(server, user, validated.client, validated.scope, tenant_opts)

          conn
          |> rotate_session()
          |> issue_code_redirect(server, user, validated)

        "deny" ->
          redirect_with_oauth_error(
            conn,
            validated.redirect_uri,
            validated.state,
            "access_denied",
            nil
          )

        _ ->
          Errors.send_oauth_error(conn, 400, "invalid_request", "missing action")
      end
    else
      {:error, :no_user} -> sign_in_redirect(conn, server)
      {:error, :bad_redirect_uri} -> bad_redirect_html(conn)
      {:error, code, desc} -> handle_authorize_error(conn, server, params, code, desc)
    end
  end

  # ── shared helpers ────────────────────────────────────────────────────────

  defp server!(opts), do: Keyword.fetch!(opts, :oauth2_server)
  defp consent_view!(opts), do: Keyword.get(opts, :consent_view, ConsentView)

  defp require_user(conn) do
    case Ash.PlugHelpers.get_actor(conn) do
      nil -> {:error, :no_user}
      user -> {:ok, user}
    end
  end

  defp issue_code_redirect(conn, server, user, validated) do
    code = Authorize.issue_code!(server, user, validated, tenant_opts(conn))

    location =
      validated.redirect_uri <>
        "?" <> URI.encode_query(%{"code" => code.id, "state" => validated.state})

    conn
    |> put_resp_header("location", location)
    |> send_resp(302, "")
    |> halt()
  end

  # RFC 6749 §4.1.2.1: when redirect_uri has been validated for the client,
  # error responses MUST go back via 302 with `error`, `error_description`,
  # and `state` so the client can surface the failure to the end user.
  # When we can't safely validate the redirect_uri (unknown client, bad URI,
  # response_type bad before client was loaded), fall back to a direct
  # error response since redirecting an unverified URI is the worse failure
  # mode (open-redirect / token leak).
  defp handle_authorize_error(conn, server, params, code, desc) do
    case safe_redirect_uri(server, params, conn) do
      {:ok, redirect_uri} ->
        redirect_with_oauth_error(conn, redirect_uri, Map.get(params, "state"), code, desc)

      :error ->
        Errors.send_oauth_error(conn, 400, code, desc)
    end
  end

  defp redirect_with_oauth_error(conn, redirect_uri, state, code, desc) do
    query =
      %{"error" => code}
      |> maybe_put_param("error_description", desc)
      |> maybe_put_param("state", state)

    conn
    |> put_resp_header("location", redirect_uri <> "?" <> URI.encode_query(query))
    |> send_resp(302, "")
    |> halt()
  end

  defp maybe_put_param(map, _key, nil), do: map
  defp maybe_put_param(map, _key, ""), do: map
  defp maybe_put_param(map, key, value), do: Map.put(map, key, value)

  defp safe_redirect_uri(server, %{"client_id" => client_id, "redirect_uri" => uri}, conn)
       when is_binary(client_id) and is_binary(uri) and client_id != "" and uri != "" do
    opts =
      [context: %{private: %{ash_authentication?: true}}]
      |> Keyword.merge(tenant_opts(conn))

    with {:ok, client} <- Ash.get(server.client_resource(), client_id, opts),
         registered when is_list(registered) <- Map.get(client, :redirect_uris) do
      normalized = Oauth2Server.__normalize_url__(uri)
      registered_normalized = Enum.map(registered, &Oauth2Server.__normalize_url__/1)
      if normalized in registered_normalized, do: {:ok, uri}, else: :error
    else
      _ -> :error
    end
  end

  defp safe_redirect_uri(_server, _params, _conn), do: :error

  defp tenant_opts(conn) do
    case Ash.PlugHelpers.get_tenant(conn) do
      nil -> []
      tenant -> [tenant: tenant]
    end
  end

  defp check_state_size(%{"state" => state}) when is_binary(state) do
    if byte_size(state) > @max_state_bytes, do: {:error, :state_too_large}, else: :ok
  end

  defp check_state_size(_), do: :ok

  # Re-key the session on the anon→consented transition so a fixated
  # pre-login session id can't carry into the consented context.
  defp rotate_session(conn), do: Plug.Conn.configure_session(conn, renew: true)

  # sobelow_skip ["XSS.SendResp"]
  defp render_consent(conn, validated, opts) do
    view = consent_view!(opts)

    assigns = %{
      client_name: validated.client.client_name,
      client_id: validated.client.id,
      redirect_uri: validated.redirect_uri,
      code_challenge: validated.code_challenge,
      scope: validated.scope,
      state: validated.state,
      resource: validated.resource,
      action_path: conn.request_path,
      csrf_token: get_csrf_token(),
      consent_request: mint_consent_request(server!(opts), validated)
    }

    body = view.render(:consent, assigns) |> IO.iodata_to_binary()

    conn
    |> put_resp_header("content-type", "text/html; charset=utf-8")
    |> put_resp_header("x-frame-options", "DENY")
    |> put_resp_header("content-security-policy", "frame-ancestors 'none'")
    |> send_resp(200, body)
    |> halt()
  end

  defp sign_in_redirect(conn, server) do
    case server.sign_in_path() do
      path when is_binary(path) ->
        return_to =
          conn.request_path <>
            if conn.query_string != "", do: "?" <> conn.query_string, else: ""

        # AshAuthentication.Phoenix sign-in handlers read `:return_to` from
        # session, not from the query string. Put it in both so any
        # convention works.
        conn
        |> Plug.Conn.put_session(:return_to, return_to)
        |> put_resp_header(
          "location",
          path <> "?" <> URI.encode_query(%{"return_to" => return_to})
        )
        |> send_resp(302, "")
        |> halt()

      _ ->
        conn |> send_resp(401, "authentication required") |> halt()
    end
  end

  # sobelow_skip ["XSS.SendResp"]
  defp bad_redirect_html(conn) do
    body = """
    <!DOCTYPE html>
    <html lang="en"><head><meta charset="UTF-8"><title>Invalid redirect URI</title>
    <style>body{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:0 1rem}</style>
    </head><body><h1>Invalid redirect URI</h1>
    <p>The <code>redirect_uri</code> does not match any registered redirect URI for this client.</p>
    <p>For security reasons, we cannot redirect you back. Please contact the application that sent you here.</p>
    </body></html>
    """

    conn
    |> put_resp_header("content-type", "text/html; charset=utf-8")
    |> send_resp(400, body)
    |> halt()
  end

  # sobelow_skip ["XSS.SendResp"]
  defp bad_state_html(conn) do
    body = """
    <!DOCTYPE html>
    <html lang="en"><head><meta charset="UTF-8"><title>Request too large</title>
    <style>body{font-family:system-ui,sans-serif;max-width:480px;margin:4rem auto;padding:0 1rem}</style>
    </head><body><h1>Request too large</h1>
    <p>The <code>state</code> parameter exceeds the maximum permitted size.</p>
    </body></html>
    """

    conn
    |> put_resp_header("content-type", "text/html; charset=utf-8")
    |> send_resp(400, body)
    |> halt()
  end

  defp get_csrf_token do
    Plug.CSRFProtection.get_csrf_token()
  rescue
    _ -> ""
  end

  # Bind the user-visible consent UI to the values that drove the code
  # issuance. The token captures everything an attacker might want to
  # silently swap (scope, code_challenge, redirect_uri, state, resource,
  # client_id) and is verified before the POST is honoured.
  defp mint_consent_request(server, validated) do
    payload = %{
      "client_id" => validated.client.id,
      "redirect_uri" => validated.redirect_uri,
      "code_challenge" => validated.code_challenge,
      "scope" => validated.scope,
      "state" => validated.state,
      "resource" => validated.resource
    }

    Plug.Crypto.sign(server.signing_secret(), @consent_request_salt, payload)
  end

  defp verify_consent_request(server, token) when is_binary(token) and token != "" do
    case Plug.Crypto.verify(server.signing_secret(), @consent_request_salt, token,
           max_age: @consent_request_max_age
         ) do
      {:ok, payload} when is_map(payload) -> {:ok, payload}
      _ -> {:error, :invalid}
    end
  end

  defp verify_consent_request(_server, _token), do: {:error, :invalid}

  # Rebuild the protocol params from the sealed payload so `validate_request`
  # operates on trusted server-side values, not the form's hidden inputs.
  defp sealed_params(sealed) do
    %{
      "response_type" => "code",
      "code_challenge_method" => "S256",
      "client_id" => Map.get(sealed, "client_id"),
      "redirect_uri" => Map.get(sealed, "redirect_uri"),
      "code_challenge" => Map.get(sealed, "code_challenge"),
      "scope" => Map.get(sealed, "scope"),
      "state" => Map.get(sealed, "state"),
      "resource" => Map.get(sealed, "resource")
    }
  end
end