Packages
attesto_phoenix
2.3.0
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/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), how they
are matched (RFC 8252 §7.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 Attesto.RedirectURI
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),
redirect_uri_matching: redirect_uri_matching(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 """
How the request `redirect_uri` is matched against the registered set
(RFC 6749 §3.1.2.3, RFC 8252 §7.3).
`:exact` - the RFC 6749 §3.1.2.3 simple string comparison - unless the host
marked this client native via `:client_native?`, in which case it gets
`:exact_allow_loopback_port` and its `http://127.0.0.1/...` /
`http://[::1]/...` redirect URI matches on any port (see `Attesto.RedirectURI`
for exactly how narrow that exception is).
Marking the client native is the whole decision. RFC 8252 §7.3 says the
authorization server MUST allow any port for a loopback redirect URI, so
requiring a second server-wide opt-in on top would mean violating that MUST
for a client the host had already declared to be an installed app.
That is the entire argument, deliberately. A tempting second one - that §8.1
and §8.4 already key on the mark alone, so §7.3 may too - does not hold up:
those are RESTRICTIONS and this is a RELAXATION, and the two have very
different blast radii when the mark is wrong. A mistaken restriction denies a
client service loudly; a mistaken relaxation widens a security check
silently. Which is why `:client_native?` is documented as an opt-in that a
host must not export by accident.
The default-off guarantee comes from `:client_native?` itself defaulting to
`false`, not from a separate flag: a deployment that classifies no clients has
no native clients and so sees exact matching everywhere, unchanged.
`native_apps: [loopback_redirect: false]` remains available as a server-wide
opt-out for a deployment that must forbid the exception outright (an operator
kill switch, or one certifying against a profile that mandates exact
matching). It defaults to `true`; it is an escape hatch, not a gate.
A CIMD client is never native - its `client_id` is an `https` URL and its
`redirect_uris` come from the document, which the authorization endpoint
additionally holds to the same origin.
"""
@spec redirect_uri_matching(Config.t(), term()) :: RedirectURI.matching()
def redirect_uri_matching(_config, {:cimd, _metadata}), do: :exact
def redirect_uri_matching(config, client) do
if client_native?(config, client) and Config.native_app_loopback_redirect?(config) do
:exact_allow_loopback_port
else
:exact
end
end
@doc """
Whether PKCE is required for this client (RFC 7636 §4.3 / RFC 9700 §2.1.1,
RFC 8252 §8.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. RFC 8252 §8.1 requires it for a native
app, so `client_native?/2` forces it as well; the requirement is stated for
public native clients, and applying it to any native client is strictly
safer - a native app that claims to be confidential cannot actually hold a
secret (§8.4). 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.
RFC 8252 §8.1 also requires `S256` rather than `plain`. That needs no policy
here: `Attesto.AuthorizationRequest` rejects `code_challenge_method=plain`
and any non-`S256` method unconditionally, for every client.
"""
@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
client_native?(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 an installed native application (RFC 8252 / BCP 212)
via the host's `:client_native?` callback.
Absent the callback, the client is NOT native. Unlike `client_public?/2` this
cannot fail closed by defaulting to `true`: "native" gates one relaxation
(§7.3 loopback ports) alongside its restrictions, and a host that has not
classified its clients must get the unmodified RFC 6749 behavior.
"""
@spec client_native?(Config.t(), term()) :: boolean()
# A CIMD client is identified by an `https` URL that resolves to a document
# served over the network; it is not an installed native app.
def client_native?(_config, {:cimd, _metadata}), do: false
def client_native?(config, client) do
Callback.invoke(Config.client_native_fun(config), [client], false) == true
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