Attesto.RedirectURI (Attesto v1.5.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.

Enabling the exception is a deliberate deployment decision: a profile that mandates exact redirect-URI matching forbids it. It is 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

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.