fix(auth): loopback redirect URIs must match on any port (RFC 8252 §7.3) #2

Closed
opened 2026-08-25 02:10:38 +00:00 by jlxq0 · 1 comment
Owner

Symptom

Claude Code CLI cannot complete Dynamic Client Registration:

Dynamic Client Registration rejected (HTTP 400): unregistered redirect_uri

Observed against caldav-mcp and diagnosed there; src/oauth_redirect.rs is copy-pasted across all five Rust MCP servers, so this repo has the same defect.

Cause

is_allowed_redirect_uri (src/oauth_redirect.rs:23) requires exact string equality against the allowlist. The allowlist carries http://localhost:8787/callback. Claude Code CLI does not use 8787: it binds a random free port per session (the observed attempt used http://localhost:3118/callback) and only consults a fixed port if every random draw fails. MCP_OAUTH_CALLBACK_PORT overrides it, but nothing sets that by default. No static entry can match, so native loopback clients are permanently locked out.

RFC 8252 §7.3:

the authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs

validate_redirect_uri already implements the loopback carve-out for the scheme check — cleartext http is permitted on loopback hosts only. The port half of the same rule was never written.

Fix

When an allowlist entry is a loopback http URI, compare scheme + host + path and ignore the port. Non-loopback entries keep exact matching: the port is a meaningful part of an https or private-scheme callback and relaxing it there would be a real hole.

Tests:

  • http://localhost:8787/callback allowlisted accepts http://localhost:3118/callback
  • and rejects http://localhost:3118/other — path still matters
  • and does not accept http://127.0.0.1:3118/callback unless 127.0.0.1 is listed separately — RFC 8252 relaxes the port, not the host
  • https://claude.ai/api/mcp/auth_callback still rejects https://claude.ai:8443/api/mcp/auth_callback

Before trusting the new tests, break the matcher and watch them go red.

Scope

Sibling issue with the full diagnosis: jlxq0/caldav-mcp#2
Also affected: carddav-mcp, jmap-mcp, m365-mcp, caldav-mcp.

No deployment manifest change is required — the existing allowlists already carry a loopback entry, which starts matching once the rule is right.

## Symptom Claude Code CLI cannot complete Dynamic Client Registration: ``` Dynamic Client Registration rejected (HTTP 400): unregistered redirect_uri ``` Observed against `caldav-mcp` and diagnosed there; `src/oauth_redirect.rs` is copy-pasted across all five Rust MCP servers, so this repo has the same defect. ## Cause `is_allowed_redirect_uri` (`src/oauth_redirect.rs:23`) requires exact string equality against the allowlist. The allowlist carries `http://localhost:8787/callback`. Claude Code CLI does **not** use 8787: it binds a random free port per session (the observed attempt used `http://localhost:3118/callback`) and only consults a fixed port if every random draw fails. `MCP_OAUTH_CALLBACK_PORT` overrides it, but nothing sets that by default. No static entry can match, so native loopback clients are permanently locked out. RFC 8252 §7.3: > the authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs `validate_redirect_uri` already implements the loopback carve-out for the *scheme* check — cleartext `http` is permitted on loopback hosts only. The port half of the same rule was never written. ## Fix When an allowlist entry is a loopback `http` URI, compare scheme + host + path and ignore the port. Non-loopback entries keep exact matching: the port is a meaningful part of an `https` or private-scheme callback and relaxing it there would be a real hole. Tests: - `http://localhost:8787/callback` allowlisted accepts `http://localhost:3118/callback` - and rejects `http://localhost:3118/other` — path still matters - and does not accept `http://127.0.0.1:3118/callback` unless `127.0.0.1` is listed separately — RFC 8252 relaxes the port, not the host - `https://claude.ai/api/mcp/auth_callback` still rejects `https://claude.ai:8443/api/mcp/auth_callback` Before trusting the new tests, break the matcher and watch them go red. ## Scope Sibling issue with the full diagnosis: https://forge.oddie.app/jlxq0/caldav-mcp/issues/2 Also affected: carddav-mcp, jmap-mcp, m365-mcp, caldav-mcp. No deployment manifest change is required — the existing allowlists already carry a loopback entry, which starts matching once the rule is right.
jlxq0 closed this issue 2026-08-25 03:51:38 +00:00
Author
Owner

Recording the production verification, because this was closed on a merged PR and a green
gate rather than on the deployed system answering about itself.

Checked against https://typst-mcp.hanso.group on 2026-08-26, driven at the allowlist the
deployment actually enforces (read off the container env with
kubectl -n typst-mcp get deploy typst-mcp -o jsonpath=..., not off an in-repo copy), whose
only loopback entries are http://localhost:8787/callback and
http://localhost:8787/oauth/callback.

Each row is a real GET /authorize with response_type=code, the DCR client id, an S256
challenge and scope=render; only redirect_uri varies.

redirect_uri result
http://localhost:8787/callback (exact allowlist entry) 303
http://localhost:54321/callback 303
http://localhost:54321/oauth/callback 303
http://127.0.0.1:54321/callback 400 unregistered redirect_uri
http://localhost:54321/wrong 400 unregistered redirect_uri
https://localhost:54321/callback 400 unregistered redirect_uri
http://evil.example.com:54321/callback 400 unregistered redirect_uri

The port is relaxed and nothing else is: localhost still does not match 127.0.0.1,
/callback still does not match /oauth/callback, https does not match an http entry,
and a non-loopback host is refused outright.

The four refusals are the point. Three 303s alone would also be produced by a matcher that
accepts everything, so they cannot distinguish a working fix from a hole. Watching it refuse
the near-misses is what makes this evidence rather than a green tick.

Live version at the time of the check: /health reported 0.2.1, running container digest
sha256:0b3da4c968bd91f470fd74f2225682da3b922845d6fa2dc91bb844f5bad216cb, matching what the
v0.2.1 tag build pushed.

Recording the production verification, because this was closed on a merged PR and a green gate rather than on the deployed system answering about itself. Checked against `https://typst-mcp.hanso.group` on 2026-08-26, driven at the allowlist the deployment actually enforces (read off the container env with `kubectl -n typst-mcp get deploy typst-mcp -o jsonpath=...`, not off an in-repo copy), whose only loopback entries are `http://localhost:8787/callback` and `http://localhost:8787/oauth/callback`. Each row is a real `GET /authorize` with `response_type=code`, the DCR client id, an S256 challenge and `scope=render`; only `redirect_uri` varies. | redirect_uri | result | |---|---| | `http://localhost:8787/callback` (exact allowlist entry) | **303** | | `http://localhost:54321/callback` | **303** | | `http://localhost:54321/oauth/callback` | **303** | | `http://127.0.0.1:54321/callback` | **400** `unregistered redirect_uri` | | `http://localhost:54321/wrong` | **400** `unregistered redirect_uri` | | `https://localhost:54321/callback` | **400** `unregistered redirect_uri` | | `http://evil.example.com:54321/callback` | **400** `unregistered redirect_uri` | The port is relaxed and nothing else is: `localhost` still does not match `127.0.0.1`, `/callback` still does not match `/oauth/callback`, `https` does not match an `http` entry, and a non-loopback host is refused outright. The four refusals are the point. Three 303s alone would also be produced by a matcher that accepts everything, so they cannot distinguish a working fix from a hole. Watching it refuse the near-misses is what makes this evidence rather than a green tick. Live version at the time of the check: `/health` reported `0.2.1`, running container digest `sha256:0b3da4c968bd91f470fd74f2225682da3b922845d6fa2dc91bb844f5bad216cb`, matching what the `v0.2.1` tag build pushed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/typst-mcp#2
No description provided.