Skip to main content

lib/attesto_phoenix/authorization_server/sender_constraint.ex

defmodule AttestoPhoenix.AuthorizationServer.SenderConstraint do
  @moduledoc """
  Sender-constraint resolution for the token endpoint (RFC 9449 / RFC 8705),
  as conn-free core.

  This is the single place that turns the sender-constraint facts of a token
  request - a presented DPoP proof (RFC 9449), a presented client certificate
  (RFC 8705), and the canonical request URL/method the proof is bound to
  (RFC 9449 §4.2 / §4.3) - together with the configured policy and the client's
  binding requirements into either a resolved binding or an
  `AttestoPhoenix.OAuthError`. The controller parses these facts off the
  `Plug.Conn` (via `AttestoPhoenix.RequestContext` and the `DPoP` request
  header) and passes them as a plain map; this module reads only data, never
  touches a conn, and never emits an event.

  ## Input

  `resolve/3` takes the validated `%AttestoPhoenix.Config{}`, the resolved
  client, and an `input` map the controller builds from the request:

    * `:dpop_proof` - the first `DPoP` request-header value (RFC 9449 §4.1), or
      `nil` when the request carries no proof.
    * `:mtls_cert_der` - the peer certificate DER (RFC 8705 §3), or `nil` when
      no client certificate was presented.
    * `:http_uri` - the canonical request URL (`htu`) the proof is bound to
      (RFC 9449 §4.3).
    * `:http_method` - the HTTP method (`htm`) the proof is bound to
      (RFC 9449 §4.2); the token endpoint is reached by POST.

  ## Return value

  `{:ok, binding, token_type}` where `binding` is one of `{:dpop, jkt}`,
  `{:mtls, thumbprint}`, or `:none`, and `token_type` is the RFC 9449 §7.1 /
  RFC 6750 presentation type (`"DPoP"` for a DPoP binding, `"Bearer"`
  otherwise). On failure, `{:error, %AttestoPhoenix.OAuthError{}}`.

  ## Precedence and fail-closed policy

  The client's *required* sender constraint is resolved first, and only the
  matching constraint type can satisfy it:

    * A client that requires DPoP (RFC 9449) is bound only by a DPoP proof. A
      request that omits the proof - even one presenting a client certificate -
      is refused (`DPoP proof required`), never silently mTLS-bound.
    * A client that requires mTLS (RFC 8705 §3) is bound only by a client
      certificate. A request that omits the certificate - even one presenting a
      DPoP proof - is refused (`client certificate required`), never silently
      DPoP-bound.

  A client's required constraint therefore cannot be satisfied by presenting a
  *different* valid constraint: the per-client policy is enforced on its own
  terms before any opportunistic binding is considered.

  Only when the client requires neither constraint does opportunistic
  precedence apply: DPoP takes precedence when a proof is presented
  (RFC 9449 §5); otherwise an mTLS certificate binds the token to its
  thumbprint; otherwise the token is an unbound Bearer.

  RFC 8705 §3: a client configured to require certificate-bound tokens MUST NOT
  be silently downgraded to a Bearer token when it calls without a certificate.
  RFC 9449 is the DPoP equivalent: a client configured for DPoP-bound issuance
  must present a proof at the token endpoint. The host's
  `:client_requires_mtls?` / `:client_requires_dpop?` callbacks gate this; both
  are read defensively and fail open only to "not required" when the host has
  not supplied the callback (the constraints are off by default per
  `:dpop_enabled` / `:mtls_enabled`).

  ## DPoP nonce challenge preserved

  When a fresh DPoP nonce is required (RFC 9449 §8 / §9), the returned
  `%AttestoPhoenix.OAuthError{}` carries the `use_dpop_nonce` code and the fresh
  `DPoP-Nonce` value in its `:headers`, so the controller renders the header
  verbatim alongside the error.
  """

  alias Attesto.DPoP.ReplayCache
  alias Attesto.MTLS
  alias AttestoPhoenix.{Callback, Config, OAuthError}
  alias AttestoPhoenix.Store.NonceStore

  @typedoc "The sender-constraint facts the controller derives from the request."
  @type input :: %{
          optional(:dpop_proof) => String.t() | nil,
          optional(:mtls_cert_der) => binary() | nil,
          optional(:http_uri) => String.t() | nil,
          optional(:http_method) => String.t() | nil
        }

  @typedoc "The resolved sender-constraint binding."
  @type binding :: {:dpop, String.t()} | {:mtls, String.t()} | :none

  # RFC 6749 §5.2 / RFC 9449 §5 error codes, held as the atoms
  # `OAuthError.new/3` requires (no string round-trip that could raise).
  @error_invalid_request :invalid_request
  @error_invalid_client :invalid_client
  @error_invalid_dpop_proof :invalid_dpop_proof
  @error_use_dpop_nonce :use_dpop_nonce

  # RFC 9449 §7.1 / RFC 6750: access-token presentation type.
  @token_type_dpop "DPoP"
  @token_type_bearer "Bearer"

  # RFC 9449 §8 / §9: the response header carrying a fresh server-issued nonce.
  @dpop_nonce_header "dpop-nonce"

  @doc """
  Resolve the sender-constraint binding for a token request.

  Returns `{:ok, binding, token_type}` or `{:error, %OAuthError{}}`. See the
  module docs for the precedence rules and the input shape.
  """
  @spec resolve(Config.t(), input(), term()) ::
          {:ok, binding(), String.t()} | {:error, OAuthError.t()}
  def resolve(%Config{} = config, input, client) do
    # Resolve the client's REQUIRED constraint first: a per-client policy must
    # be enforced on its own terms, so a client cannot satisfy its required
    # constraint by presenting a DIFFERENT (valid) one. Only a client that
    # requires neither falls through to opportunistic binding.
    cond do
      client_requires_dpop?(config, client) ->
        resolve_required_dpop(config, input)

      client_requires_mtls?(config, client) ->
        resolve_required_mtls(config, input)

      true ->
        resolve_opportunistic(config, input)
    end
  end

  # RFC 9449: a DPoP-required client is bound only by a DPoP proof. A request
  # that omits the proof - even one presenting a client certificate - is refused
  # rather than mTLS-bound, so the required constraint cannot be satisfied by a
  # different type. A presented proof is verified and binds DPoP (or surfaces a
  # DPoP-specific error / nonce challenge from `bind_dpop/2`).
  defp resolve_required_dpop(config, input) do
    if config.dpop_enabled and dpop_present?(input) do
      bind_dpop(config, input)
    else
      # The token request omits a required proof entirely; return a standard
      # OAuth token-endpoint error so FAPI clients can classify the grant
      # attempt without relying on DPoP-specific error vocabulary.
      {:error, error(@error_invalid_request, "DPoP proof required")}
    end
  end

  # RFC 8705 §3: an mTLS-required client is bound only by a client certificate.
  # A request that omits the certificate - even one presenting a DPoP proof - is
  # refused rather than DPoP-bound, so the required constraint cannot be
  # satisfied by a different type.
  defp resolve_required_mtls(config, input) do
    if config.mtls_enabled and mtls_cert_present?(input) do
      bind_mtls(input)
    else
      {:error, error(@error_invalid_client, "client certificate required")}
    end
  end

  # The client requires neither constraint: bind whichever single constraint it
  # opportunistically presents. DPoP takes precedence over a presented
  # certificate (RFC 9449 §5); absent both, the token is an unbound Bearer.
  defp resolve_opportunistic(config, input) do
    cond do
      config.dpop_enabled and dpop_present?(input) ->
        bind_dpop(config, input)

      config.mtls_enabled and mtls_cert_present?(input) ->
        bind_mtls(input)

      true ->
        {:ok, :none, @token_type_bearer}
    end
  end

  @doc """
  Sender-constraint audit metadata derivable from a token request.

  This records the sender-constraint method attempted at the request boundary
  using the same precedence as `resolve/3`, without verifying a DPoP proof or
  certificate. It is intended for denial events, including failures that happen
  before a binding can be resolved.
  """
  @spec audit_metadata(Config.t(), input()) :: %{
          token_type: String.t(),
          sender_constraint: :none | :dpop | :mtls,
          cnf: nil
        }
  def audit_metadata(%Config{} = config, input) do
    cond do
      config.dpop_enabled and dpop_present?(input) ->
        %{token_type: @token_type_dpop, sender_constraint: :dpop, cnf: nil}

      config.mtls_enabled and mtls_cert_present?(input) ->
        %{token_type: @token_type_bearer, sender_constraint: :mtls, cnf: nil}

      true ->
        %{token_type: @token_type_bearer, sender_constraint: :none, cnf: nil}
    end
  end

  @doc """
  The `Attesto.Token.mint/3` confirmation opt for a resolved `binding`
  (RFC 9449 / RFC 8705).

  DPoP binds `cnf.jkt`; mTLS binds `cnf.x5t#S256` (the certificate thumbprint,
  threaded so a real `cnf` is minted rather than dropped); an unbound binding
  carries no opt.
  """
  @spec mint_opts(binding()) :: keyword()
  def mint_opts(:none), do: []
  def mint_opts({:dpop, jkt}), do: [dpop_jkt: jkt]
  def mint_opts({:mtls, thumbprint}), do: [mtls_cert_thumbprint: thumbprint]

  @doc """
  The DPoP thumbprint a stateful grant (authorization-code redemption, refresh
  rotation) binds to. Only DPoP flows through those engines' `:dpop_jkt` opt;
  an mTLS binding carries no DPoP thumbprint.
  """
  @spec binding_jkt(binding()) :: String.t() | nil
  def binding_jkt({:dpop, jkt}), do: jkt
  def binding_jkt(_binding), do: nil

  @doc """
  The DPoP thumbprint to bind a refresh token to (RFC 9449 §8).

  Public clients get DPoP-bound refresh tokens; for confidential clients the
  refresh token stays bound to the authenticated `client_id` (RFC 6749 §6 /
  §10.4) rather than one DPoP proof key, so no DPoP thumbprint is threaded.
  """
  @spec refresh_binding_jkt(Config.t(), term(), binding()) :: String.t() | nil
  def refresh_binding_jkt(%Config{} = config, client, binding) do
    if client_public?(config, client), do: binding_jkt(binding)
  end

  @doc """
  Whether the client requires DPoP-bound token issuance (RFC 9449).

  Read defensively; fails open to "not required" when the host supplies no
  `:client_requires_dpop?` callback.
  """
  @spec client_requires_dpop?(Config.t(), term()) :: boolean()
  # A CIMD client (`draft-ietf-oauth-client-id-metadata-document-01`) is governed
  # by its metadata document, not the host's per-client sender-constraint policy,
  # so the host callback does not apply: it does not require DPoP.
  def client_requires_dpop?(%Config{}, {:cimd, _metadata}), do: false

  def client_requires_dpop?(%Config{} = config, client) do
    Callback.invoke(Config.client_requires_dpop_fun(config), [client], false) == true
  end

  @doc """
  Whether the client requires certificate-bound token issuance (RFC 8705).

  Read defensively; fails open to "not required" when the host supplies no
  `:client_requires_mtls?` callback.
  """
  @spec client_requires_mtls?(Config.t(), term()) :: boolean()
  # A CIMD client is governed by its document, not the host's per-client policy,
  # so the host callback does not apply: it does not require mTLS.
  def client_requires_mtls?(%Config{}, {:cimd, _metadata}), do: false

  def client_requires_mtls?(%Config{} = config, client) do
    Callback.invoke(Config.client_requires_mtls_fun(config), [client], false) == true
  end

  # ----- internal -----

  defp dpop_present?(input), do: is_binary(dpop_proof(input))

  defp mtls_cert_present?(input), do: is_binary(mtls_cert_der(input))

  defp bind_dpop(config, input) do
    proof = dpop_proof(input)

    verify_opts =
      [
        http_method: http_method(input),
        http_uri: http_uri(input),
        # RFC 9449 §11.1: record the proof's `jti` so a captured token-endpoint
        # proof cannot be replayed within its acceptance window. The token
        # endpoint is the one place RFC 9449 §11.1 makes the server responsible
        # for the replay check itself (there is no later resource-server check to
        # rely on), so it must be wired here, exactly as the PAR endpoint does.
        replay_check: replay_check(config)
      ]
      |> put_optional_kw(:nonce_check, nonce_check(config))

    case invoke_dpop_verify(proof, verify_opts) do
      {:ok, %{jkt: jkt}} ->
        {:ok, {:dpop, jkt}, @token_type_dpop}

      {:error, :use_dpop_nonce} ->
        # RFC 9449 §8/§9: hand the client a fresh nonce and demand a retry.
        {:error, dpop_nonce_required(config)}

      {:error, reason} ->
        {:error, error(@error_invalid_dpop_proof, "invalid DPoP proof: #{inspect(reason)}")}
    end
  end

  # The proof verifier is part of the `Attesto.DPoP` core; the replay-check
  # callback is host-supplied. Both are reached only through the configured
  # surface so this module hardcodes neither a store nor a clock.
  defp invoke_dpop_verify(proof, opts) do
    Attesto.DPoP.verify_proof(proof, opts)
  end

  defp bind_mtls(input) do
    case mtls_cert_der(input) do
      der when is_binary(der) ->
        case MTLS.compute_thumbprint(der) do
          {:ok, x5t} ->
            # RFC 8705 §3: the certificate thumbprint becomes the token's
            # `cnf.x5t#S256` (minted via `Attesto.Token`'s
            # `:mtls_cert_thumbprint` opt). mTLS-bound tokens keep the
            # `Bearer` type (RFC 8705 §3.1).
            {:ok, {:mtls, x5t}, @token_type_bearer}

          {:error, _reason} ->
            # The presented bytes are not a parseable X.509 certificate, so there
            # is nothing to bind a token to (RFC 8705 §3 binds the SHA-256 of a
            # certificate's DER). This is a malformed request parameter, not a
            # client-authentication failure (the client may have authenticated by
            # secret), so it surfaces as `invalid_request`.
            {:error, error(@error_invalid_request, "invalid client certificate")}
        end

      _ ->
        {:error, error(@error_invalid_client, "client certificate required")}
    end
  end

  # RFC 9449 §8/§9: when the deployment requires server-issued nonces
  # (`config.dpop_nonce_required`), hand `Attesto.DPoP.verify_proof/2` a
  # `:nonce_check` callback that validates the proof's `nonce` claim against
  # the configured `Attesto.DPoP.NonceStore`. The callback receives the
  # proof's `nonce` (which may be `nil` if the client sent none) and returns
  # `:ok` only for a currently-valid nonce, else `{:error, :use_dpop_nonce}`
  # so the controller answers with a fresh `DPoP-Nonce`. When nonces are not
  # required, no callback is supplied and the engine enforces none.
  defp nonce_check(%Config{dpop_nonce_required: true, nonce_store: store} = config)
       when is_atom(store) and not is_nil(store) do
    fn nonce ->
      if NonceStore.valid?(config, store, nonce), do: :ok, else: {:error, :use_dpop_nonce}
    end
  end

  defp nonce_check(_config), do: nil

  # RFC 9449 §11.1: the host may supply a `:replay_check` (`(jti, exp) -> :ok |
  # {:error, _}`); otherwise the package's `Attesto.DPoP.ReplayCache` records the
  # `jti` and rejects a repeat. Mirrors the PAR endpoint's resolution so the two
  # DPoP entry points share one replay store.
  defp replay_check(%Config{replay_check: nil}), do: &ReplayCache.check_and_record/2
  # A host configures `:replay_check` as a `{module, function}` MFA (config holds
  # no literal fn), but `Attesto.DPoP.verify_proof/2` requires a bare 2-arity
  # function. Adapt every callback form into a closure before handing it over.
  defp replay_check(%Config{replay_check: callback}), do: Callback.to_fun2(callback)

  # RFC 9449 §8: issue a fresh server nonce and return it in the error's
  # `:headers` so the controller can replay the `DPoP-Nonce` header verbatim,
  # telling the client to retry its proof with the `nonce` claim included.
  defp dpop_nonce_required(config) do
    nonce = issue_nonce(config)

    error(@error_use_dpop_nonce, "DPoP proof requires a server-issued nonce",
      status: 400,
      headers: [{@dpop_nonce_header, nonce}]
    )
  end

  defp issue_nonce(%Config{nonce_store: store} = config) when is_atom(store) and not is_nil(store) do
    NonceStore.issue(config, store)
  end

  defp issue_nonce(_config), do: ""

  # A CIMD client holds no symmetric secret, so it is public by construction (it
  # leans on PKCE / DPoP downstream); a registered client defers to the host's
  # `:client_public?` discriminator.
  defp client_public?(_config, {:cimd, _metadata}), do: true

  defp client_public?(config, client) do
    Callback.invoke(Config.client_public_fun(config), [client], false) == true
  end

  defp dpop_proof(input), do: Map.get(input, :dpop_proof)
  defp mtls_cert_der(input), do: Map.get(input, :mtls_cert_der)
  defp http_uri(input), do: Map.get(input, :http_uri)
  defp http_method(input), do: Map.get(input, :http_method)

  defp put_optional_kw(kw, _key, nil), do: kw
  defp put_optional_kw(kw, key, value), do: Keyword.put(kw, key, value)

  # `code` is a compile-time RFC 6749 §5.2 / RFC 9449 §5 error-code atom, passed
  # straight to `OAuthError.new/3` (which requires an atom). No string-to-atom
  # round-trip that could raise before the atom exists.
  defp error(code, description) do
    OAuthError.new(code, description, status: 400)
  end

  defp error(code, description, opts) do
    OAuthError.new(code, description,
      status: Keyword.get(opts, :status, 400),
      headers: Keyword.get(opts, :headers, [])
    )
  end
end