Skip to main content

lib/ash_authentication_phoenix/oauth2_server/protocol_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.ProtocolRouter do
  @moduledoc """
  Plug router for the client-facing OAuth 2.1 protocol endpoints — anything
  called by an external OAuth client without a browser session.

  Endpoints handled:

    * `GET /oauth-authorization-server` — RFC 8414 metadata
    * `GET /oauth-protected-resource`   — RFC 9728 metadata
    * `GET /openid-configuration`       — alias for OIDC-conformant tooling
    * `POST /register`                  — RFC 7591 Dynamic Client Registration
    * `POST /token`                     — authorization_code + refresh_token grants
    * `POST /revoke`                    — RFC 7009 token revocation

  Mount this behind your API pipeline (no CSRF, no session needed). For the
  human-driven consent step (`/authorize`), see
  `AshAuthentication.Phoenix.Oauth2Server.ConsentRouter`.

  ## Options

    * `:oauth2_server` (required) — the user's `Oauth2Server` config module
  """

  use Plug.Router, copy_opts_to_assign: :oauth2_server_router_opts

  alias AshAuthentication.Oauth2Server.{Metadata, Register, Token}
  alias AshAuthentication.Phoenix.Oauth2Server.Errors

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

  plug :match
  plug :dispatch

  # ── metadata ───────────────────────────────────────────────────────────────

  get("/oauth-authorization-server", do: serve_authorization_server_metadata(conn))
  get("/openid-configuration", do: serve_authorization_server_metadata(conn))

  # sobelow_skip ["XSS.SendResp"]
  get "/oauth-protected-resource" do
    server = server!(conn.assigns.oauth2_server_router_opts)

    conn
    |> put_resp_header("content-type", "application/json")
    |> put_resp_header("cache-control", "public, max-age=3600")
    |> send_resp(200, Jason.encode!(Metadata.protected_resource(server, secret_context(conn))))
    |> halt()
  end

  # ── DCR ────────────────────────────────────────────────────────────────────

  post "/register" do
    server = server!(conn.assigns.oauth2_server_router_opts)
    opts = [initial_access_token: extract_bearer(conn)] ++ tenant_opts(conn)

    case Register.register(server, conn.params, opts) do
      {:ok, _client, body} ->
        conn
        |> put_resp_header("content-type", "application/json")
        |> put_resp_header("cache-control", "no-store")
        |> send_resp(201, Jason.encode!(body))
        |> halt()

      {:error, :dcr_disabled} ->
        # DCR is off on this server. Treat the route as not present —
        # consistent with the metadata document not advertising it.
        conn |> send_resp(404, "") |> halt()

      {:error, :invalid_initial_access_token} ->
        # RFC 7591 §3.2.2 — Bearer-auth failure, not a metadata error.
        Errors.send_bearer_error(
          conn,
          401,
          "invalid_token",
          "registration requires a valid initial access token"
        )

      {:error, code, desc} ->
        Errors.send_dcr_error(conn, code, desc)
    end
  end

  # ── token ──────────────────────────────────────────────────────────────────

  post "/token" do
    server = server!(conn.assigns.oauth2_server_router_opts)
    params = conn.params || %{}
    opts = tenant_opts(conn)

    result =
      case Map.get(params, "grant_type") do
        "authorization_code" -> Token.exchange_authorization_code(server, params, opts)
        "refresh_token" -> Token.exchange_refresh_token(server, params, opts)
        _ -> {:error, :unsupported_grant_type}
      end

    case result do
      {:ok, response} ->
        conn
        |> put_resp_header("content-type", "application/json")
        |> put_resp_header("cache-control", "no-store")
        |> send_resp(200, Jason.encode!(token_response_json(response)))
        |> halt()

      {:error, :unsupported_grant_type} ->
        Errors.send_oauth_error(conn, 400, "unsupported_grant_type", nil)

      {:error, reason} ->
        {status, code, desc} = Errors.describe_token_error(reason)
        Errors.send_oauth_error(conn, status, code, desc)
    end
  end

  # ── revocation (RFC 7009) ──────────────────────────────────────────────────

  # Always 200, regardless of whether the token existed or matched the client
  # — RFC 7009 §2.2 requires the endpoint not to leak token state.
  post "/revoke" do
    server = server!(conn.assigns.oauth2_server_router_opts)
    :ok = Token.revoke(server, conn.params || %{}, tenant_opts(conn))

    conn
    |> put_resp_header("cache-control", "no-store")
    |> send_resp(200, "")
    |> halt()
  end

  # ── default ────────────────────────────────────────────────────────────────

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

  # ── helpers ───────────────────────────────────────────────────────────────

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

  # Read the Ash tenant set upstream (browser plug, header parser, etc.)
  # and forward it to the protocol-core functions. Returns `[]` for
  # single-tenant deployments so the caller can splice without an `if`.
  defp tenant_opts(conn) do
    case Ash.PlugHelpers.get_tenant(conn) do
      nil -> []
      tenant -> [tenant: tenant]
    end
  end

  # Pull the bearer token out of `Authorization: Bearer <token>` if
  # present. Used by `/register` to forward an RFC 7591 initial access
  # token into the protocol core.
  defp extract_bearer(conn) do
    case Plug.Conn.get_req_header(conn, "authorization") do
      ["Bearer " <> token | _] when token != "" -> token
      ["bearer " <> token | _] when token != "" -> token
      _ -> nil
    end
  end

  # sobelow_skip ["XSS.SendResp"]
  defp serve_authorization_server_metadata(conn) do
    server = server!(conn.assigns.oauth2_server_router_opts)

    conn
    |> put_resp_header("content-type", "application/json")
    |> put_resp_header("cache-control", "public, max-age=3600")
    |> send_resp(
      200,
      Jason.encode!(Metadata.authorization_server(server, secret_context(conn)))
    )
    |> halt()
  end

  defp secret_context(conn) do
    case Ash.PlugHelpers.get_tenant(conn) do
      nil -> %{}
      tenant -> %{tenant: tenant}
    end
  end

  defp token_response_json(%{} = response) do
    %{
      "access_token" => response.access_token,
      "token_type" => response.token_type,
      "expires_in" => response.expires_in,
      "refresh_token" => response.refresh_token,
      "scope" => response.scope
    }
  end
end