AttestoPhoenix.RequestContext (AttestoPhoenix v2.2.0)

Copy Markdown View Source

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-For only for trusted proxies;
  • whether the request effectively arrived over HTTPS (RFC 8446), honoring a trusted X-Forwarded-Proto: https hop;
  • 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 cnf binding;
  • 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 - matches 127.0.0.0/8 and ::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

canonical_url(conn, config)

@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.

cert_der(conn, config)

@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.

check_embedded_user_agent(conn, config)

@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.

check_https(conn, config)

@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).

client_ip(conn, config)

@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.

embedded_user_agent?(conn)

@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/1 and drops the wv token 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-Agent and 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.

from_trusted_proxy?(conn, config)

@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.

http_method(conn)

@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.

https?(conn, config)

@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.