Attesto.RedirectURI (Attesto v1.14.0)

Copy Markdown View Source

Redirect-URI matching for the authorization endpoint (RFC 6749 §3.1.2.3, RFC 8252 §7.3).

RFC 6749 §3.1.2.3 matches a request redirect_uri against the client's registered set by simple string comparison: byte-for-byte, no normalization and no prefix matching. That is the default here, and it is what the OpenID Connect and FAPI profiles assume.

RFC 8252 (BCP 212) defines one narrow exception. A native application that cannot use a private-use URI scheme (§7.1) instead binds an ephemeral port on the loopback interface (§7.3) and only learns that port at runtime, so the port cannot be registered ahead of time. §7.3 therefore requires the authorization server to "allow any port to be specified at the time of the request for loopback IP redirect URIs", while comparing the rest of the URI exactly.

Matching modes

  • :exact (default) - RFC 6749 §3.1.2.3 simple string comparison, and nothing else.

  • :exact_allow_loopback_port - exact comparison first; failing that, the RFC 8252 §7.3 loopback exception. The exception applies only when BOTH the request URI and the registered URI are loopback redirect URIs, meaning each one:

    • begins with the byte-exact scheme http:// (an https URI, a private-use scheme, and an upper-case HTTP:// are all outside the exception and stay exact-match);

    • has an authority of exactly 127.0.0.1 or [::1], optionally followed by :<port> and nothing else. Any userinfo, any other host, and any other spelling of the loopback address are outside the exception.

      localhost is deliberately excluded. RFC 8252 §8.3 makes the literal IP preferable precisely because localhost is a name, resolved by the device's host-name configuration, and so is not guaranteed to be the loopback interface. Extending port flexibility to a name whose resolution the server cannot reason about would widen the exception past what §7.3 asks for, so http://localhost:PORT/... never matches under it.

      RFC 9700 §2.1 and §4.1.3 phrase the same exception as applying to "localhost redirection URIs of native apps", which reads as a wider allowance. Both, however, define it by reference - "as described in Section 7.3 of [RFC8252]" - and §7.3 constructs the URI from the loopback IP literal, not the name. localhost there is shorthand for "the loopback interface", so the normative content is §7.3's and this module follows it. A client that has registered a localhost URI is unaffected in the ordinary case: it still matches exactly, it just gets no port flexibility.

      This scopes the exception, not registration: a client that has registered a localhost redirect URI still reaches it by exact match, since that URI is one the host deliberately registered and §8.3's "NOT RECOMMENDED" is guidance to the client about which URI to choose, not a requirement that the server refuse it. Such a client simply gets no port flexibility;

    • carries no fragment (a redirect URI must not, RFC 6749 §3.1.2).

    The two sides differ in one respect. The request URI names an endpoint the server is about to redirect a browser to, so a port it carries must be decimal 1..65535 or absent; an empty (http://127.0.0.1:/cb) or out-of-range port is not a reachable endpoint and falls back to exact comparison. The registered URI is a pattern whose port is discarded, so any port stands there - including the conventional :0 placeholder for "an ephemeral port chosen at runtime".

    Two loopback URIs match when their scheme, host literal, path, and query are all identical; only the port is ignored. IPv4 and IPv6 loopback are distinct hosts and never match each other.

  • :exact_allow_loopback_port_including_localhost - everything :exact_allow_loopback_port does, and additionally treats the bare hostname localhost as a loopback authority, so http://localhost:<ephemeral>/cb matches a registered http://localhost/cb.

    This exists for interoperability, not because §7.3 requires it. §7.3's MUST is scoped to "loopback IP redirect URIs" and the paragraphs above are the right default. But nothing forbids a server allowing the name, and §8.3's case against localhost is stated entirely in terms of what the client does - it "avoids inadvertently listening on network interfaces other than the loopback interface" and is "less susceptible to client-side firewalls and misconfigured host name resolution on the user's device". Those are reasons for an app author to choose the IP literal; refusing port flexibility at the authorization server does not make any of them true, it only fails the request. Real native clients exist that register a portless localhost callback and then bind an ephemeral port, and they cannot authorize at all under the stricter rule.

    The residual risk is bounded by two allowances this module already makes independently: a registered localhost URI is already reachable by exact match, so redirecting to a name the server cannot resolve is already accepted; and §7.3 already mandates port flexibility, so a code landing on an arbitrary local port is already accepted for 127.0.0.1. This mode permits their combination and nothing else, which is why it is a separate opt-in rather than a relaxation of :exact_allow_loopback_port.

    localhost is its own host identity, never folded onto 127.0.0.1: the name and the IP literals do not cross-match, exactly as IPv4 and IPv6 loopback do not. Every other constraint above is unchanged - byte-exact http:// scheme, anchored authority (so localhost.evil.example, sub.localhost, evil-localhost, localhost. and any userinfo stay outside), no fragment, exact path and query, and the same asymmetric port rule between the request and registered sides.

Enabling either exception is a deliberate deployment decision: a profile that mandates exact redirect-URI matching forbids them. Both are off by default so the matching behavior is unchanged unless a host asks for it.

Registration convention

A client registers its loopback redirect URI with whatever port it likes (http://127.0.0.1/cb, http://127.0.0.1:0/cb, or a fixed port) and any usable request port then matches. Only the port is variable; a request that differs in path or query is still rejected.

Failure is never a redirect

This module answers a boolean. A request URI that matches nothing is not a trusted redirect target, and the caller MUST report the failure directly to the user agent rather than redirecting to the supplied URI (OIDC Core §3.1.2.6) - otherwise the endpoint is an open redirect.

Parser agreement

Matching is only as sound as the agreement between the parser that decides and the parser that navigates. Elixir's URI follows RFC 3986; the browser that receives the Location follows the WHATWG URL Standard, and the two disagree about some authorities. In https://evil.example\@client.example/cb, RFC 3986 reads evil.example\ as userinfo and client.example as the host, while WHATWG treats the backslash as a path separator and navigates to evil.example.

Byte-exact matching is immune - it compares strings, never origins - and the §7.3 loopback exception is immune because it anchors on the whole authority rather than the parsed host. Any check phrased in terms of a host or an origin is not, so unambiguous?/1 exists to keep such a URI out of a registered set in the first place.

Summary

Types

The redirect-URI matching mode (see the moduledoc).

Functions

Normalize a caller-supplied matching mode, raising ArgumentError on an unrecognized value.

The supported matching modes. Exposed so a caller can validate host configuration against the same list this module enforces.

Whether uri matches one of the client's registered redirect URIs under matching (RFC 6749 §3.1.2.3, RFC 8252 §7.3).

Whether every URL parser agrees which origin uri names (see "Parser agreement" in the moduledoc).

Types

matching()

@type matching() ::
  :exact
  | :exact_allow_loopback_port
  | :exact_allow_loopback_port_including_localhost

The redirect-URI matching mode (see the moduledoc).

Functions

matching!(matching)

@spec matching!(term()) :: matching()

Normalize a caller-supplied matching mode, raising ArgumentError on an unrecognized value.

A misspelled mode must never silently degrade into a different matching policy, in either direction: quietly falling back to :exact would hide a host's deliberate opt-in, and quietly enabling anything else would relax matching nobody asked for. Raising makes the misconfiguration visible.

matching_modes()

@spec matching_modes() :: [matching()]

The supported matching modes. Exposed so a caller can validate host configuration against the same list this module enforces.

registered?(uri, registered, matching \\ :exact)

@spec registered?(String.t(), [String.t()], matching()) :: boolean()

Whether uri matches one of the client's registered redirect URIs under matching (RFC 6749 §3.1.2.3, RFC 8252 §7.3).

A non-binary entry in registered is ignored rather than raising: the registered set comes from the host, and one malformed entry must not make an otherwise valid request crash the endpoint.

unambiguous?(uri)

@spec unambiguous?(term()) :: boolean()

Whether every URL parser agrees which origin uri names (see "Parser agreement" in the moduledoc).

Answers false for a URI carrying anything that makes RFC 3986 and the WHATWG URL Standard read a different authority out of the same bytes:

  • a backslash anywhere. WHATWG maps \ to / in a special scheme, so it can terminate an authority that RFC 3986 reads as continuing.
  • userinfo. https://a@b/ is unambiguous today, but userinfo is the component every authority-confusion trick is built out of, and a redirect URI has no legitimate use for credentials (RFC 6749 §3.1.2 wants a plain absolute URI). Refusing it removes the whole class rather than the one spelling known to differ.
  • a C0 control, space, tab, CR, or LF. WHATWG strips tab/CR/LF before parsing and percent-encodes the rest; RFC 3986 does neither.

A URI that does not parse at all is likewise false - it is not a target the server can reason about.

This is a check on what may be registered, not a matching mode. It says nothing about whether a URI is a good redirect target, only that the answer will not depend on which parser is asked.