Attesto.RedirectURI (Attesto v1.4.1)

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.

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

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.