Neutral request-fact helpers the OAuth 2.0 / OIDC flows derive from a Plug.Conn.
Authorization-server endpoints need a handful of transport-level facts that are
not safe to read straight off the Plug.Conn when the listener sits behind a
reverse proxy:
- the client IP, honoring
X-Forwarded-Foronly for trusted proxies; - whether the request effectively arrived over HTTPS (RFC 8446), honoring
a trusted
X-Forwarded-Proto: httpshop; - the canonical request URL (
htu) and method (htm) a DPoP proof is bound to, per RFC 9449 §4.2 / §4.3; - the peer certificate DER presented at the TLS layer, used for the
RFC 8705 §3 mutual-TLS
cnfbinding; - whether the request appears to come from an embedded user agent (an in-app webview), which RFC 8252 §8.12 recommends refusing at the authorization endpoint.
Every forwarded-header-derived fact is gated on a trusted-proxy allowlist. A
request that arrives from a peer outside that allowlist with forged
X-Forwarded-* headers is a spoofing attempt: the headers are dropped and the
fact is derived from the direct connection only. This is fail-closed by
construction, an untrusted peer cannot assert https, cannot redirect the
DPoP htu, and cannot forge a client IP.
The trust boundary, the HTTPS requirement, and the optional certificate
extractor are read from AttestoPhoenix.Config; this module never hardcodes
deployment policy.
Trusted-proxy allowlist
config.trusted_proxies controls whether X-Forwarded-* headers are honored.
It accepts a list whose elements are any of:
:loopback- matches127.0.0.0/8and::1.:any- matches every peer. Only safe when another mechanism (firewall, ingress ACL) guarantees that only the proxy can reach the app port. Prefer explicit CIDRs.- an IP tuple (
{10, 0, 0, 1}/ an 8-element IPv6 tuple) - exact match. - a binary CIDR string (
"10.0.0.0/8","::1/128") - subnet match.
The default ([]) trusts no proxy, so forwarded headers are never honored
unless the host opts in.
Summary
Functions
Returns the canonical request URL (htu) the DPoP proof is bound to, per
RFC 9449 §4.3: the request URI without its query or fragment.
Returns the peer certificate DER for the RFC 8705 §3 mutual-TLS cnf binding,
or nil when no client certificate was presented.
Returns :ok when the request satisfies the configured embedded-user-agent
policy, or {:error, :embedded_user_agent} when the host has enabled
native_apps: [reject_embedded_user_agents: true] and the request looks like
it came from an in-app webview (RFC 8252 §8.12).
Returns :ok when the request satisfies the configured transport policy, or
{:error, :insecure_transport} when config.require_https is set and the
request did not effectively arrive over HTTPS.
Returns the client IP as a string, or nil if it cannot be determined.
Returns true when the request's User-Agent looks like an embedded user
agent - an in-app webview - rather than the system browser (RFC 8252 §8.12).
Returns true when conn.remote_ip falls inside config.trusted_proxies.
Returns the HTTP method (htm) the DPoP proof is bound to, per RFC 9449 §4.2.
Returns true when the request effectively arrived over HTTPS.
Functions
@spec canonical_url(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: String.t()
Returns the canonical request URL (htu) the DPoP proof is bound to, per
RFC 9449 §4.3: the request URI without its query or fragment.
When config.htu is set, that callback is used so a host can fully override
URL reconstruction (e.g. for a proxy topology this module does not model).
Otherwise the URL is built from the effective scheme/host/port, which honor
X-Forwarded-Proto / X-Forwarded-Host / X-Forwarded-Port only when the
request comes from a trusted proxy. An untrusted peer therefore cannot
redirect the htu check by injecting forwarded headers: the URL falls back to
the direct connection's authority and the proof either verifies against the
real listener URL or fails on its signature.
@spec cert_der(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: binary() | nil
Returns the peer certificate DER for the RFC 8705 §3 mutual-TLS cnf binding,
or nil when no client certificate was presented.
When config.cert_der is set (required by AttestoPhoenix.Config whenever
mtls_enabled is true), that callback extracts the DER; this is the supported
override for proxy topologies that surface the client certificate in a header
rather than on the TLS socket. Otherwise the certificate is read from the
connection's peer data, which the underlying adapter populates when the TLS
socket negotiated client authentication.
@spec check_embedded_user_agent(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: :ok | {:error, :embedded_user_agent}
Returns :ok when the request satisfies the configured embedded-user-agent
policy, or {:error, :embedded_user_agent} when the host has enabled
native_apps: [reject_embedded_user_agents: true] and the request looks like
it came from an in-app webview (RFC 8252 §8.12).
Returns :ok unconditionally when the flag is off, which is the default. The
caller reports the failure directly to the user agent - there is no trusted
redirect_uri at this point, and redirecting the flow onward inside the
webview would defeat the purpose of refusing it.
This applies to the front-channel authorization request only. The PAR and
token endpoints are back-channel calls from the client itself, where no user
agent is involved and the User-Agent carries no such meaning.
@spec check_https(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: :ok | {:error, :insecure_transport}
Returns :ok when the request satisfies the configured transport policy, or
{:error, :insecure_transport} when config.require_https is set and the
request did not effectively arrive over HTTPS.
This is the fail-closed transport check the token and protected-resource endpoints run before touching a credential: a bearer token or client secret that has already crossed a plain-HTTP hop must be treated as compromised, so the request is refused rather than served or redirected (a redirect would have the client replay the exposed credential).
@spec client_ip(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: String.t() | nil
Returns the client IP as a string, or nil if it cannot be determined.
When the request comes from a trusted proxy and carries X-Forwarded-For, the
left-most entry (the original client per RFC 7239 / the de-facto
X-Forwarded-For convention) is returned. Otherwise the direct connection's
remote_ip is used. An untrusted peer cannot forge the client IP this way: its
X-Forwarded-For is ignored entirely.
@spec embedded_user_agent?(Plug.Conn.t()) :: boolean()
Returns true when the request's User-Agent looks like an embedded user
agent - an in-app webview - rather than the system browser (RFC 8252 §8.12).
§8.12 recommends that an authorization server not permit an embedded user
agent for the authorization request: the application hosting the webview can
read the page's DOM, inject scripts, and capture keystrokes, so the user's
credentials are exposed to the very party the authorization is meant to
constrain. The native-app remedy is an external user agent - the system
browser, an in-app browser tab (SFSafariViewController /
Android Custom Tabs) - which the host application cannot inspect.
This is a heuristic
The User-Agent is client-supplied and unauthenticated. A webview can send
any string, so this check is trivially evaded by an attacker who wants to; and
because it matches on product tokens and platform quirks it will
misclassify some honest browsers. It is therefore a defense-in-depth hint,
never a security boundary, and the enforcement it backs is off by default (see
check_embedded_user_agent/2).
Both error directions are real and known:
- False negatives. Any Android webview whose host app calls
setUserAgentString/1and drops thewvtoken is missed, as is any in-app browser whose product token is not on the list. The list names the largest ones; it cannot be complete. - False positives. An iOS web app launched from the home screen
(standalone display mode) is indistinguishable from a WKWebView by
User-Agentand is flagged. Deployments serving such an app should leave this off.
Only the User-Agent is consulted. X-Requested-With is deliberately
ignored: Android webviews set it to the host application's package name, but
so have some Custom Tabs implementations, and Custom Tabs is precisely the
RFC 8252 §8.1-recommended arrangement - keying on that header would refuse the
behavior the RFC asks for.
A request with no User-Agent is not treated as embedded: absence is not
evidence, and refusing it would break non-browser callers.
@spec from_trusted_proxy?(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: boolean()
Returns true when conn.remote_ip falls inside config.trusted_proxies.
This is the single trust gate that governs whether any X-Forwarded-* header
is honored. It is exposed so callers that need a custom forwarded-header read
can apply the same boundary rather than re-implementing it and risking drift.
@spec http_method(Plug.Conn.t()) :: String.t()
Returns the HTTP method (htm) the DPoP proof is bound to, per RFC 9449 §4.2.
The method is taken verbatim from the request; it is not derived from any forwarded header.
@spec https?(Plug.Conn.t(), AttestoPhoenix.Config.t()) :: boolean()
Returns true when the request effectively arrived over HTTPS.
The effective scheme is the connection scheme, upgraded to https when a
trusted proxy forwards X-Forwarded-Proto: https. An untrusted peer's
forwarded scheme is ignored, so a plain-HTTP hop cannot masquerade as TLS.