defmodule AttestoPhoenix.AuthorizationServer.PAR do
@moduledoc """
Pushed Authorization Request storage (RFC 9126), as conn-free core.
This is the single place that turns an authenticated client and a parsed
authorization request into either a stored `request_uri` reference or an
`AttestoPhoenix.OAuthError`. The Pushed Authorization Request endpoint
(RFC 9126) authenticates the client (RFC 6749 §2.3), stores the submitted
authorization request parameters behind a `request_uri`, and returns that
reference to be used at the authorization endpoint, which still performs the
normal client/redirect/scope/PKCE validation when the reference is resolved.
## North star
`AttestoPhoenix.Controller.PARController` parses the request off the
`Plug.Conn`, authenticates the client via `AttestoPhoenix.ClientAuthentication`
(RFC 6749 §2.3), lifts the DPoP facts into a `%Request{}` of plain data, and
calls `store/2`. This module reads only data, never touches a conn, and never
emits an event. Policy is carried on the `%AttestoPhoenix.Config{}` the caller
passes in (the `:par_store` persistence, the `:par_ttl`, the host callbacks);
nothing is hardcoded here.
## Return value
`{:ok, %{request_uri: request_uri, expires_in: ttl}}` on success, where
`request_uri` is a freshly generated `urn:ietf:params:oauth:request_uri:`
reference (RFC 9126 §2.2) and `ttl` is the configured lifetime in seconds.
`{:error, %AttestoPhoenix.OAuthError{}}` on failure.
## Security details preserved
* The stored record carries the authenticated `client_id` resolved through
the host's `:client_id` callback (RFC 6749 §2.2), overriding any
body-supplied value. When no `:client_id` callback is configured the
request's own presented `client_id` is left intact (not clobbered with
`nil`); the library makes no assumption about the opaque client shape. The
client-authentication credentials (`client_secret`, `client_assertion`,
`client_assertion_type`) are dropped before storage.
* RFC 9449: when a `DPoP` proof is presented at the PAR endpoint, it is
verified against the canonical request URL/method (RFC 9449 §4.2 / §4.3)
with the configured replay check, and its `jkt` is stored as the
`dpop_jkt` the authorization code will later be sender-constrained to. A
submitted `dpop_jkt` request parameter that disagrees with the verified
proof's thumbprint is rejected (`invalid_dpop_proof`). Presenting more
than one `DPoP` proof is rejected (RFC 9449 §4.1). A `dpop_jkt` parameter
submitted without a proof is honoured as-is, since the proof of possession
is demonstrated later at the token endpoint.
"""
alias Attesto.DPoP
alias Attesto.DPoP.ReplayCache
alias Attesto.RequestObject
alias AttestoPhoenix.AuthorizationServer.PAR.Request
alias AttestoPhoenix.AuthorizationServer.RequestPolicy
alias AttestoPhoenix.{Callback, ClientIdMetadata, Config, OAuthError}
alias AttestoPhoenix.Store.PAR.ETS
@typedoc """
The conn-free DPoP facts the controller lifts off the PAR request
(RFC 9449 §4.1 / §4.2 / §4.3).
* `:proofs` - the `DPoP` request-header values
(`Plug.Conn.get_req_header(conn, "dpop")`); `[]` when no proof was
presented, more than one entry being a rejected ambiguous request.
* `:http_uri` - the canonical request URL (`htu`) the proof is bound to.
* `:http_method` - the HTTP method (`htm`) the proof is bound to.
"""
@type dpop_input :: %{
optional(:proofs) => [String.t()],
optional(:http_uri) => String.t() | nil,
optional(:http_method) => String.t() | nil
}
# RFC 6749 §5.2 / RFC 9449 error codes.
@error_invalid_request "invalid_request"
@error_invalid_request_object "invalid_request_object"
@error_invalid_dpop_proof "invalid_dpop_proof"
# RFC 9126 §2.2: the `request_uri` reference scheme.
@request_uri_prefix "urn:ietf:params:oauth:request_uri:"
# RFC 9126 §2.2: the default `request_uri` lifetime, in seconds, when the host
# configures no `:par_ttl`.
@default_par_ttl 90
@doc """
Store a pushed authorization request, returning the `request_uri` reference
and its lifetime, or an error.
`config` is the validated `%AttestoPhoenix.Config{}` carrying the `:par_store`
persistence, the `:par_ttl`, and the host callbacks; `request` is the
`AttestoPhoenix.AuthorizationServer.PAR.Request` the controller built from the
authenticated client, the request body, and the conn-free DPoP facts. See the
module docs for the return shape and the security details preserved.
"""
@spec store(Config.t(), Request.t()) ::
{:ok, %{request_uri: String.t(), expires_in: pos_integer()}}
| {:error, OAuthError.t()}
def store(%Config{} = config, %Request{} = request) do
%{client: client, params: params, dpop_input: dpop_input} = request
ttl = config_field(config, :par_ttl, @default_par_ttl)
request_uri = @request_uri_prefix <> random()
# Verify the request object FIRST so its signed parameters are authoritative
# (RFC 9101 §6.3) before DPoP reconciliation: a signed `dpop_jkt` must be the
# value the presented proof is checked against, never an unsigned body value.
with :ok <- reject_request_uri(params),
{:ok, params} <- verify_request_object(config, client, params),
:ok <- validate_pushed_request(config, client, params),
{:ok, dpop_jkt} <- verify_dpop_binding(config, dpop_input, params) do
stored =
params
|> Map.drop(["client_secret", "client_assertion", "client_assertion_type"])
|> put_verified_dpop_jkt(dpop_jkt)
|> put_resolved_client_id(client_id(config, client))
case par_store(config).put(request_uri, stored, ttl) do
:ok ->
{:ok, %{request_uri: request_uri, expires_in: ttl}}
_ ->
{:error, error(@error_invalid_request, "could not store pushed authorization request")}
end
end
end
# RFC 9126 §2.1 (step 2): the PAR endpoint MUST reject a request that itself
# carries a `request_uri` parameter - a client cannot push a reference to
# another reference. Checked on the RAW pushed parameters, before request-
# object verification, so a `request` object replacing the parameter set
# cannot mask a `request_uri` smuggled in as a sibling form parameter.
defp reject_request_uri(%{"request_uri" => value}) when is_binary(value) and value != "" do
detail = "request_uri must not be used at the PAR endpoint"
{:error, error(@error_invalid_request, detail)}
end
defp reject_request_uri(_params), do: :ok
# RFC 9126 §2.1 step 3: validate the pushed request as the authorization
# endpoint would - the request `redirect_uri` must exactly match one of the
# client's registered URIs (RFC 6749 §3.1.2.3), the `response_type`/PKCE/
# `response_mode` must be valid - so an invalid request is refused early here
# rather than only when the `request_uri` is later resolved at /authorize. The
# `RequestPolicy` resolvers are shared with the authorization endpoint so both
# validate identically. The signed `request` object, already verified and
# merged into `params` above, is dropped before validation so it is not
# re-verified (its parameters are already authoritative). Every PAR error is a
# direct response (RFC 9126 §2.3), never a redirect, so a redirectable
# classification is flattened to its OAuth error code.
defp validate_pushed_request(config, client, params) do
case RequestPolicy.validate(config, client, Map.delete(params, "request")) do
{:ok, _request} -> :ok
{:error, reason} -> {:error, par_validation_error(reason)}
end
end
# OIDC Core §3.1.2.6 classifies a redirect_uri/client_id failure as
# non-redirectable; at the PAR endpoint there is no redirect either way, so
# both the direct and the (would-be) redirect classifications collapse to a
# direct RFC 6749 §5.2 error response, preserving the error code the
# authorization endpoint would have surfaced.
defp par_validation_error({:direct, reason}) do
error(@error_invalid_request, "invalid authorization request: #{reason}")
end
defp par_validation_error({:redirect, %{error: code, error_description: description}}) do
OAuthError.new(validation_code_atom(code), description, status: 400)
end
# The RFC 6749 §4.1.2.1 / §5.2 error codes `Attesto.AuthorizationRequest`
# raises for a redirectable failure, mapped to the atoms `OAuthError` expects.
# An unrecognised code falls back to `invalid_request` (the §5.2 catch-all).
defp validation_code_atom("invalid_request"), do: :invalid_request
defp validation_code_atom("invalid_request_object"), do: :invalid_request_object
defp validation_code_atom("invalid_scope"), do: :invalid_scope
# RFC 8707 §2: a malformed `resource` indicator pushed via PAR.
defp validation_code_atom("invalid_target"), do: :invalid_target
defp validation_code_atom("unsupported_response_type"), do: :unsupported_response_type
defp validation_code_atom("request_not_supported"), do: :request_not_supported
defp validation_code_atom("request_uri_not_supported"), do: :request_uri_not_supported
defp validation_code_atom(_other), do: :invalid_request
# RFC 9449: bind the pushed request to a DPoP proof when one is presented.
# No proof keeps any submitted `dpop_jkt` parameter as-is (proof of possession
# is demonstrated later at the token endpoint); a single proof is verified and
# its thumbprint stored; more than one proof is an ambiguous request
# (RFC 9449 §4.1) and is rejected.
defp verify_dpop_binding(config, dpop_input, params) do
case dpop_proofs(dpop_input) do
[] ->
{:ok, submitted_dpop_jkt(params)}
[proof] ->
verify_dpop_proof(config, dpop_input, params, proof)
_multiple ->
{:error, error(@error_invalid_dpop_proof, "multiple DPoP proofs")}
end
end
defp verify_dpop_proof(config, dpop_input, params, proof) do
opts = [
http_method: http_method(dpop_input),
http_uri: http_uri(dpop_input),
replay_check: replay_check(config)
]
with {:ok, %{jkt: verified_jkt}} <- DPoP.verify_proof(proof, opts),
:ok <- check_submitted_dpop_jkt(Map.get(params, "dpop_jkt"), verified_jkt) do
{:ok, verified_jkt}
else
{:error, reason} ->
{:error, error(@error_invalid_dpop_proof, "invalid DPoP proof: #{inspect(reason)}")}
end
end
# RFC 9449: a submitted `dpop_jkt` is honoured only when it matches the proof
# the client actually demonstrated; a disagreement is a confused request and
# is rejected (an absent or empty `dpop_jkt` is no constraint to reconcile).
defp check_submitted_dpop_jkt(nil, _verified_jkt), do: :ok
defp check_submitted_dpop_jkt("", _verified_jkt), do: :ok
defp check_submitted_dpop_jkt(verified_jkt, verified_jkt), do: :ok
defp check_submitted_dpop_jkt(_submitted_jkt, _verified_jkt), do: {:error, :dpop_jkt_mismatch}
defp put_verified_dpop_jkt(params, nil), do: params
defp put_verified_dpop_jkt(params, dpop_jkt), do: Map.put(params, "dpop_jkt", dpop_jkt)
defp submitted_dpop_jkt(%{"dpop_jkt" => jkt}) when is_binary(jkt) and jkt != "", do: jkt
defp submitted_dpop_jkt(_params), do: nil
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)
# The client's identifier (RFC 6749 §2.2), resolved through the host's
# `:client_id` callback. When no `:client_id` callback is configured the
# identifier cannot be derived from the opaque client struct (`nil`), matching
# the resolution used everywhere else in the library.
# A CIMD client (`draft-ietf-oauth-client-id-metadata-document-01`) is
# identified by the URL its document is bound to; a registered client by the
# host's `:client_id` callback.
defp client_id(_config, {:cimd, metadata}), do: ClientIdMetadata.client_id(metadata)
defp client_id(config, client) do
Callback.invoke(Config.client_id_fun(config), [client], nil)
end
# Store the authenticated `client_id` when it resolves. When it does not (no
# `:client_id` callback), leave the request's own presented `client_id`
# intact rather than clobbering it with `nil`. The prior
# `client[:id]`/`client["id"]` struct-shape fallback is intentionally gone -
# the library makes no assumption about the opaque host client shape.
defp put_resolved_client_id(params, nil), do: params
defp put_resolved_client_id(params, client_id), do: Map.put(params, "client_id", client_id)
defp par_store(config), do: config_field(config, :par_store, ETS)
defp dpop_proofs(dpop_input), do: Map.get(dpop_input, :proofs, [])
defp http_uri(dpop_input), do: Map.get(dpop_input, :http_uri)
defp http_method(dpop_input), do: Map.get(dpop_input, :http_method)
defp random, do: 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
defp config_field(config, field, default) do
case Map.get(config, field) do
nil -> default
value -> value
end
end
# FAPI 2.0 Message Signing §5.3.1: when a signed `request` object is pushed,
# the AS verifies it AT the PAR endpoint (not only later at /authorize), so a
# bad JAR is rejected here. Verification uses the authenticated client's
# trusted JWKS, the issuer audience, and the configured request-object policy
# (`Attesto.RequestObject.Policy`; default generic OIDC §6.1).
#
# On success the VERIFIED request-object parameters become the stored request
# (RFC 9101 §6.3: when a request object is present its signed parameters are
# authoritative and unsigned body parameters are ignored), so the stored PAR
# record never carries unsigned body values beside a verified object. The
# compact `request` JWT is retained so /authorize re-verifies it too. A PAR
# carrying no `request` object is stored as-is - requiring its presence is a
# separate profile concern.
defp verify_request_object(config, client, %{"request" => request}) when is_binary(request) and request != "" do
opts =
[issuer: client_id(config, client), audience: config.issuer] ++
RequestObject.Policy.to_verify_opts(request_object_policy(config))
case RequestObject.verify(request, client_jwks(config, client) || %{"keys" => []}, opts) do
{:ok, object_params} ->
{:ok, Map.put(object_params, "request", request)}
{:error, _reason} ->
{:error, error(@error_invalid_request_object, "request object is invalid")}
end
end
# No `request` object pushed. FAPI 2.0 Message Signing §5.3.1: when the
# configured policy requires a signed request object, a PAR carrying none is
# rejected here (RFC 9126 §2.3 invalid_request) rather than stored as a plain
# request; otherwise the pushed plain parameters stand (generic OIDC §6.1).
defp verify_request_object(config, _client, params) do
if RequestObject.Policy.require_request_object?(request_object_policy(config)) do
{:error,
error(
@error_invalid_request,
"pushed authorization request must use a signed request object"
)}
else
{:ok, params}
end
end
defp request_object_policy(config), do: config.request_object_policy || %RequestObject.Policy{}
# Resolve the client's trusted JWK set, mirroring the authorize controller's
# resolution (the host's `:client_jwks` callback, returning a JWKS or `nil`).
# A CIMD client's request-object verification keys are the document's
# `jwks` / `jwks_uri` (RFC 9101 §6.2); a registered client's are the host's
# `:client_jwks` callback.
defp client_jwks(_config, {:cimd, metadata}), do: ClientIdMetadata.jwks(metadata)
defp client_jwks(config, client) do
case Config.client_jwks_fun(config) do
nil ->
nil
callback ->
case Callback.invoke(callback, [client]) do
{:ok, jwks} -> jwks
jwks when is_map(jwks) or is_list(jwks) -> jwks
_other -> nil
end
end
end
defp error(code, description) do
OAuthError.new(code_atom(code), description, status: 400)
end
defp code_atom(@error_invalid_request), do: :invalid_request
defp code_atom(@error_invalid_request_object), do: :invalid_request_object
defp code_atom(@error_invalid_dpop_proof), do: :invalid_dpop_proof
end