Packages
attesto_phoenix
2.0.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/par.ex
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