Skip to main content

lib/attesto_phoenix/authorization_server/request_policy.ex

defmodule AttestoPhoenix.AuthorizationServer.RequestPolicy do
  @moduledoc """
  Conn-free resolution of the per-request authorization-request validation
  policy shared by the authorization endpoint and the PAR endpoint.

  Both endpoints validate the same authorization request the same way (RFC 9126
  §2.1: "validate the pushed request as it would an authorization request sent
  to the authorization endpoint"), so the policy inputs `Attesto.AuthorizationRequest.validate/2`
  needs - the client's registered redirect URIs (RFC 6749 §3.1.2.3), whether
  PKCE is required (RFC 9700 §2.1.1), and whether `nonce` is required (OIDC Core
  §3.1.2.1) - are resolved here once, from `%AttestoPhoenix.Config{}` and the
  opaque host client, rather than duplicated per endpoint. This module reads
  only data: it touches no `conn` and carries no policy of its own beyond the
  fail-closed defaults documented on each function.
  """

  alias Attesto.AuthorizationRequest
  alias AttestoPhoenix.AuthorizationServer.SenderConstraint
  alias AttestoPhoenix.{Callback, ClientIdMetadata, Config}

  @doc """
  Validate `params` as an authorization request for `client`, resolving the
  redirect-URI/PKCE/nonce policy from `config` and delegating to
  `Attesto.AuthorizationRequest.validate/2`.

  This is the shared entry point both the authorization endpoint and the PAR
  endpoint use so a request is validated identically wherever it arrives
  (RFC 9126 §2.1). Pass `extra_opts` to thread request-object verification
  inputs (`:request_object_jwks`, `:request_object_audience`,
  `:request_object_policy`) when the `params` still carry an unverified signed
  `request` object; omit them when the object has already been verified and
  merged.
  """
  @spec validate(Config.t(), term(), map(), keyword()) ::
          {:ok, AuthorizationRequest.t()} | {:error, AuthorizationRequest.error()}
  def validate(config, client, params, extra_opts \\ []) do
    opts =
      [
        registered_redirect_uris: registered_redirect_uris(config, client),
        require_pkce: require_pkce?(config, client),
        require_nonce: require_nonce?(config)
      ] ++ extra_opts

    AuthorizationRequest.validate(params, opts)
  end

  @doc """
  The client's registered redirect URIs (RFC 6749 §3.1.2.3).

  For a CIMD client (`{:cimd, metadata}`,
  `draft-ietf-oauth-client-id-metadata-document-01`) the document *is* the
  registration, so the registered set is the document's own `redirect_uris`
  (RFC 9700) and the host's per-client callback is never consulted. For a
  registered client the set is resolved through the host's
  `:client_redirect_uris` callback; an absent callback or a non-list return
  resolves to `[]`, which rejects every request with an unregistered redirect
  URI (fail closed).
  """
  @spec registered_redirect_uris(Config.t(), term()) :: [String.t()]
  def registered_redirect_uris(_config, {:cimd, metadata}), do: ClientIdMetadata.redirect_uris(metadata)

  def registered_redirect_uris(config, client) do
    case Callback.invoke(Config.client_redirect_uris_fun(config), [client], []) do
      uris when is_list(uris) -> uris
      _ -> []
    end
  end

  @doc """
  Whether PKCE is required for this client (RFC 7636 §4.3 / RFC 9700 §2.1.1).

  A public client MUST use PKCE, so `client_public?/2` forces it regardless of
  config. A sender-constrained client (DPoP or mTLS) is a FAPI 2.0 client, and
  FAPI 2.0 Security Profile §5.3.1.2 / RFC 9700 §2.1.1 require PKCE for it even
  though it authenticates confidentially - so `client_requires_dpop?/2` and
  `client_requires_mtls?/2` force it too. For any other confidential client the
  global `:require_pkce` flag applies (default `true`). Fail closed: absent the
  host's deliberate opt-out, PKCE is required.
  """
  @spec require_pkce?(Config.t(), term()) :: boolean()
  # A CIMD client is public (`none` + PKCE) or `private_key_jwt`; PKCE is
  # mandatory for it regardless of the host's `:require_pkce` flag, and the host
  # sender-constraint callbacks do not apply to the document.
  def require_pkce?(_config, {:cimd, _metadata}), do: true

  def require_pkce?(config, client) do
    client_public?(config, client) or
      SenderConstraint.client_requires_dpop?(config, client) or
      SenderConstraint.client_requires_mtls?(config, client) or
      Callback.config_flag(config, :require_pkce)
  end

  @doc """
  Classify the client as public via the host's `:client_public?` callback.

  Absent the callback, fail closed by treating the client as public, so PKCE
  stays required (a confidential exemption demands a deliberate host
  classification).
  """
  @spec client_public?(Config.t(), term()) :: boolean()
  # A CIMD client holds no symmetric secret (document validation strips it), so
  # it is public by construction.
  def client_public?(_config, {:cimd, _metadata}), do: true

  def client_public?(config, client) do
    case Config.client_public_fun(config) do
      nil -> true
      callback -> Callback.invoke(callback, [client]) == true
    end
  end

  @doc """
  The host's OP nonce policy flag (OIDC Core §3.1.2.1).

  Returns the raw `:require_nonce` configuration. The OIDC openid-scope gate is
  NOT applied here: it must run on the EFFECTIVE request (after any signed
  `request` object is merged), which only `Attesto.AuthorizationRequest.validate/2`
  sees. Applying the gate on the raw outer params here would let a direct JAR
  carrying `scope=openid` only inside the signed object bypass the requirement.
  """
  @spec require_nonce?(Config.t()) :: boolean()
  def require_nonce?(config) do
    Callback.config_flag(config, :require_nonce)
  end
end