Skip to main content

lib/ash_authentication_phoenix/oauth2_server/errors.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.Errors do
  @moduledoc """
  HTTP error response helpers for OAuth 2.1 / RFC 7591.
  """

  import Plug.Conn

  @doc """
  Send a JSON error per OAuth 2.0 / RFC 6749 §5.2.

  Codes: `"invalid_request"`, `"invalid_client"`, `"invalid_grant"`,
  `"unsupported_grant_type"`, `"invalid_scope"`, etc.
  """
  # sobelow_skip ["XSS.SendResp"]
  @spec send_oauth_error(Plug.Conn.t(), pos_integer(), String.t(), String.t() | nil) ::
          Plug.Conn.t()
  def send_oauth_error(conn, status, code, description \\ nil) do
    body = %{"error" => code} |> maybe_put("error_description", description)

    conn
    |> put_resp_header("content-type", "application/json")
    |> put_resp_header("cache-control", "no-store")
    |> send_resp(status, Jason.encode!(body))
    |> halt()
  end

  @doc """
  Send a 400 with an RFC 7591 DCR-shaped error.

  Codes: `"invalid_redirect_uri"`, `"invalid_client_metadata"`.
  """
  def send_dcr_error(conn, code, description \\ nil) do
    send_oauth_error(conn, 400, code, description)
  end

  @doc """
  Send a Bearer-auth error per RFC 6750 §3 — JSON body + a
  `WWW-Authenticate: Bearer error="…", error_description="…"` header.

  Used for failures of Bearer-authenticated endpoints (e.g. RFC 7591
  initial-access-token failures on `/oauth/register`).
  """
  # sobelow_skip ["XSS.SendResp"]
  @spec send_bearer_error(Plug.Conn.t(), pos_integer(), String.t(), String.t() | nil) ::
          Plug.Conn.t()
  def send_bearer_error(conn, status, code, description \\ nil) do
    challenge =
      [{"error", code}, {"error_description", description}]
      |> Enum.reject(fn {_, v} -> is_nil(v) end)
      |> Enum.map_join(", ", fn {k, v} -> ~s|#{k}="#{escape_quoted(v)}"| end)

    body = %{"error" => code} |> maybe_put("error_description", description)

    conn
    |> put_resp_header("content-type", "application/json")
    |> put_resp_header("cache-control", "no-store")
    |> put_resp_header("www-authenticate", "Bearer " <> challenge)
    |> send_resp(status, Jason.encode!(body))
    |> halt()
  end

  # WWW-Authenticate quoted-string values: backslash-escape `"` and `\`.
  defp escape_quoted(value) do
    value
    |> String.replace("\\", "\\\\")
    |> String.replace("\"", "\\\"")
  end

  @doc """
  Translate a `:reason` atom returned from a core module into an
  `{http_status, error_code, description}` triple suitable for an OAuth
  error response.
  """
  @spec describe_token_error(atom()) :: {pos_integer(), String.t(), String.t()}
  def describe_token_error(reason) do
    case reason do
      :reuse -> {400, "invalid_grant", "code or refresh token already used"}
      :expired -> {400, "invalid_grant", "expired"}
      :pkce -> {400, "invalid_grant", "PKCE verification failed"}
      :resource_mismatch -> {400, "invalid_grant", "resource does not match"}
      :redirect_mismatch -> {400, "invalid_grant", "redirect_uri mismatch"}
      :invalid_code -> {400, "invalid_grant", "code not found or invalid"}
      :invalid_refresh -> {400, "invalid_grant", "refresh token invalid"}
      :revoked -> {400, "invalid_grant", "refresh token revoked"}
      :client_mismatch -> {400, "invalid_grant", "client mismatch"}
      :invalid_request -> {400, "invalid_request", "missing required parameters"}
      :refresh_create_failed -> {500, "server_error", "could not issue refresh token"}
      _ -> {400, "invalid_request", "request could not be processed"}
    end
  end

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
end