Noizu.MCP.Auth.Server.RedirectURI (Noizu MCP v0.1.6)

Copy Markdown View Source

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

Everything 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

error()

@type error() ::
  :invalid_redirect_uri
  | :insecure_scheme
  | :fragment_not_allowed
  | :userinfo_not_allowed
  | :host_not_allowed

Functions

host_allowed?(host, allowed)

@spec host_allowed?(String.t() | nil, [String.t()]) :: boolean()

Match a host against an allowlist: exact, or a subdomain on a label boundary.

loopback?(uri)

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

True for a loopback redirect URI, whose port is matched loosely.

matches?(requested, registered)

@spec matches?(term(), [String.t()] | String.t()) :: boolean()

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.

resolve(requested, registered)

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

valid?(uri, opts \\ [])

@spec valid?(
  term(),
  keyword()
) :: boolean()

True when the URI passes validate/2.

validate(uri, opts \\ [])

@spec validate(
  term(),
  keyword()
) :: {:ok, String.t()} | {:error, error()}

Validate a redirect URI as registered or as presented at the authorization endpoint.

Options:

  • :allowed_hosts — when set, the URI's host must satisfy host_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). Default false.
  • :allow_http_hosts — extra hosts allowed over plain http (a development-only escape hatch). Default [].