Redirect-URI validation and matching (RFC 6749 §3.1.2, RFC 8252, OAuth 2.1).
Matching is exact string comparison with exactly one carve-out, and the
carve-out is forced on us: http://127.0.0.1:PORT/callback for a native
client (RFC 8252 §7.3). Claude Code and Codex bind an ephemeral loopback
port, so the port they register can never be the port they come back on. For
loopback redirect URIs the port is therefore ignored, and only the port —
scheme, host and path still have to match byte-for-byte.
iex> registered = ["http://127.0.0.1/callback", "https://claude.ai/api/mcp/auth_callback"]
iex> Noizu.MCP.Auth.Server.RedirectURI.matches?("http://127.0.0.1:53821/callback", registered)
true
iex> Noizu.MCP.Auth.Server.RedirectURI.matches?("http://127.0.0.1:53821/other", registered)
falseEverything else that makes redirect matching go wrong is refused up front:
no fragment (RFC 6749 forbids one, and it is where an appended ?error=
hides), no userinfo (https://claude.ai@evil.example), no wildcards, and
http only for loopback.
Host suffix matching
host_allowed?/2 — used to constrain what a dynamically-registered client may
register — matches a host exactly or as a subdomain on a label boundary.
Naive String.ends_with?/2 accepts evil-claude.ai for claude.ai, which
hands an attacker a valid-looking registration:
iex> Noizu.MCP.Auth.Server.RedirectURI.host_allowed?("evil-claude.ai", ["claude.ai"])
false
iex> Noizu.MCP.Auth.Server.RedirectURI.host_allowed?("api.claude.ai", ["claude.ai"])
true
Summary
Functions
Match a host against an allowlist: exact, or a subdomain on a label boundary.
True for a loopback redirect URI, whose port is matched loosely.
True when requested matches one of the client's registered URIs.
Resolve which registered URI a request matched, or {:error, :invalid_redirect_uri}.
True when the URI passes validate/2.
Validate a redirect URI as registered or as presented at the authorization endpoint.
Types
Functions
Match a host against an allowlist: exact, or a subdomain on a label boundary.
True for a loopback redirect URI, whose port is matched loosely.
True when requested matches one of the client's registered URIs.
Exact comparison, except that a loopback URI's port is ignored (see the module doc). Neither side is normalized beyond that: case-folding a path or dropping a trailing slash here would silently widen every registration.
@spec resolve(String.t() | nil, [String.t()]) :: {:ok, String.t()} | {:error, :invalid_redirect_uri}
Resolve which registered URI a request matched, or {:error, :invalid_redirect_uri}.
A request with no redirect_uri resolves to the client's single
registered URI, and is an error when the client registered more than one —
guessing between them is how a code lands at the wrong callback.
True when the URI passes validate/2.
Validate a redirect URI as registered or as presented at the authorization endpoint.
Options:
:allowed_hosts— when set, the URI's host must satisfyhost_allowed?/2. This is the DCR guard (MCP_DCR_ALLOWED_REDIRECT_HOSTS); loopback URIs are exempt, since a native client's loopback callback is not a host anyone can claim.:allow_custom_scheme— permit a private-use scheme (com.example.app:/cb, RFC 8252 §7.1). Defaultfalse.:allow_http_hosts— extra hosts allowed over plainhttp(a development-only escape hatch). Default[].