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

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:27) 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, m365-mcp, typst-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:27`) 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, m365-mcp, typst-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 02:23:51 +00:00
Author
Owner

Correction: the diagnosis in this issue was wrong when it was filed

jmap-mcp did not have this defect. c9f5ae1 fix(oauth): accept ephemeral loopback ports landed on 2026-08-19, six days before this issue, and is contained in tags v0.2.11 through v0.2.14. The live deployment on Fondue was already running v0.2.14. src/oauth_redirect.rs:27 has delegated to loopback_redirect_matches since then; it has not demanded exact string equality.

Verified against the running service, using this issue's own failing case:

request result
POST /register with http://localhost:3118/callback 201, client issued
http://evil.example/callback 400 unregistered redirect_uri
https://claude.ai:8443/api/mcp/auth_callback 400 unregistered redirect_uri

How the error was made, since that is the part worth keeping: I grepped fn is_allowed_redirect_uri across all five Rust MCP repos, got a line number back from each, and wrote five issues as though identical filenames implied identical function bodies. I never read this repo's. Four of the five were right by luck of the copies not having diverged in that direction; this one was not.

The check that would have caught it costs one command — git log -- src/oauth_redirect.rs — and the worker assigned here ran it before writing any code, which is why nothing wrong was committed.

What the PR that closed this actually fixed

A real gap, in the tests rather than the code. Mutating loopback_redirect_matches to compare scheme + host + path only — relaxing the port on every scheme, including https://claude.ai/… — left allowlist_matches_exact_redirect_uri_only green. Nothing asserted the fourth case from this issue's body, so the suite could not distinguish the correct fix from the dangerous one.

Three mutations, each watched red before being trusted:

mutation red test
exact string equality (the original bug) loopback_allowlist_varies_only_the_port
drop scheme/host guards, keep path+query allowlist_accepts_private_use_schemes, loopback_allowlist_varies_only_the_port
relax port on every scheme allowlist_matches_exact_redirect_uri_only

No release tag, deliberately

The change is test-only and the behaviour it covers already runs in production as v0.2.14. A v0.2.15 would build a functionally identical image, open a Renovate PR against oddie-apps/platform and trigger an ArgoCD sync for no behaviour change. Left at v0.2.14.

Sibling status: caldav-mcp v0.1.2 and m365-mcp v0.1.7 shipped real fixes; carddav-mcp and typst-mcp genuinely still had exact-string matching on main and are in flight. This repo was the only false positive.

## Correction: the diagnosis in this issue was wrong when it was filed `jmap-mcp` did not have this defect. `c9f5ae1 fix(oauth): accept ephemeral loopback ports` landed on 2026-08-19, six days before this issue, and is contained in tags v0.2.11 through v0.2.14. The live deployment on Fondue was already running v0.2.14. `src/oauth_redirect.rs:27` has delegated to `loopback_redirect_matches` since then; it has not demanded exact string equality. Verified against the running service, using this issue's own failing case: | request | result | |---|---| | `POST /register` with `http://localhost:3118/callback` | **201**, client issued | | `http://evil.example/callback` | 400 unregistered redirect_uri | | `https://claude.ai:8443/api/mcp/auth_callback` | 400 unregistered redirect_uri | **How the error was made**, since that is the part worth keeping: I grepped `fn is_allowed_redirect_uri` across all five Rust MCP repos, got a line number back from each, and wrote five issues as though identical filenames implied identical function bodies. I never read this repo's. Four of the five were right by luck of the copies not having diverged in that direction; this one was not. The check that would have caught it costs one command — `git log -- src/oauth_redirect.rs` — and the worker assigned here ran it before writing any code, which is why nothing wrong was committed. ## What the PR that closed this actually fixed A real gap, in the tests rather than the code. Mutating `loopback_redirect_matches` to compare scheme + host + path only — relaxing the port on **every** scheme, including `https://claude.ai/…` — left `allowlist_matches_exact_redirect_uri_only` green. Nothing asserted the fourth case from this issue's body, so the suite could not distinguish the correct fix from the dangerous one. Three mutations, each watched red before being trusted: | mutation | red test | |---|---| | exact string equality (the original bug) | `loopback_allowlist_varies_only_the_port` | | drop scheme/host guards, keep path+query | `allowlist_accepts_private_use_schemes`, `loopback_allowlist_varies_only_the_port` | | relax port on every scheme | `allowlist_matches_exact_redirect_uri_only` | ## No release tag, deliberately The change is test-only and the behaviour it covers already runs in production as v0.2.14. A v0.2.15 would build a functionally identical image, open a Renovate PR against `oddie-apps/platform` and trigger an ArgoCD sync for no behaviour change. Left at v0.2.14. Sibling status: `caldav-mcp` v0.1.2 and `m365-mcp` v0.1.7 shipped real fixes; `carddav-mcp` and `typst-mcp` genuinely still had exact-string matching on `main` and are in flight. This repo was the only false positive.
Sign in to join this conversation.
No labels
waiting-on-julian
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/jmap-mcp#8
No description provided.