Packages
attesto_phoenix
2.0.1
2.3.0
2.2.0
2.1.0
2.0.2
2.0.1
2.0.0
1.4.0
1.3.0
1.2.0
1.1.0
1.0.0
0.20.0
0.19.1
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.2
0.14.1
0.14.0
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
Phoenix/Ecto OAuth 2.0 / OIDC authorization server layer over attesto: authorization, token, PAR, revocation, discovery, JWKS, UserInfo, protected-resource plugs, and Ecto-backed token stores.
Current section
Files
Jump to
Current section
Files
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