Add RFC 8252 loopback-aware redirect URI matching to auth#56
Conversation
Native and CLI OAuth2 clients (e.g. MCP clients) bind an ephemeral loopback port per run and cannot register it ahead of time, so exact redirect_uri matching rejects every re-authentication after the first. RedirectUris wraps a client's registered redirect URIs and matches a presented redirect_uri exactly, except that http loopback URIs (localhost, 127.0.0.1, [::1]) match with any port per RFC 8252 Section 7.3. The loopback hosts are an exact allowlist, the host itself must still match, and scheme, path, and query compare exactly.
Greptile SummaryThis PR adds
Confidence Score: 5/5Safe to merge — the change is entirely additive (new class, new tests, new docs) with no modifications to existing code paths. The loopback allowlist rejects lookalikes and percent-encoded variants, the IPv6 [::1] constant matches what PHP's parse_url actually returns, and path/query/fragment/userinfo are all handled strictly. Test coverage is thorough across all security-relevant boundaries. No files require special attention. Important Files Changed
Reviews (1): Last reviewed commit: "(style): collapse empty constructor body" | Re-trigger Greptile |
What
Adds
Utopia\Auth\OAuth2\RedirectUris, a value object over an OAuth2 client's registered redirect URIs with RFC 8252 §7.3 loopback-aware matching, plus tests and docs.Why
Native and CLI OAuth2 clients (e.g. MCP clients such as Claude Code) bind an ephemeral loopback port per run and cannot register it ahead of time. They register once via dynamic client registration (with whatever port they held at the time), cache the
client_id, and present a different port on every subsequent authentication. An authorization server doing exactredirect_uristring matching rejects every re-authentication after the first withinvalid_redirect_uri— observed in production againstfra.cloud.appwrite.io(client registered withhttp://localhost:3118/callback, later presentedhttp://localhost:54155/callback).RFC 8252 §7.3 requires authorization servers to allow any port for loopback redirect URIs for exactly this reason.
How
matches()does exact string comparison first (zero behavior change for non-loopback clients), then a loopback fallback: when both the presented and a registered URI arehttpURIs whose host is exactlylocalhost,127.0.0.1, or[::1]with no userinfo or fragment, they compare on host, path, and query while ignoring the port.Deliberately strict:
localhost.evil.comnever qualifieslocalhostdoes not match127.0.0.1httpsand custom schemes stay exact-only/from()tolerates malformed stored entries (they never match anything) since the registered list is existing data, not boundary input.Consumers
Appwrite Cloud will replace the four inline exact-match checks (
Authorize,PAR,Approve,Reject) with this matcher after the nextutopia-php/authrelease.Testing
RedirectUrisTest) covering exact matching, port variance across all three loopback hosts, host/scheme/path/query/fragment/userinfo strictness, and malformed-input robustnessbin/monorepo test auth— 193 tests, 440 assertions, greenbin/monorepo check auth— pint, phpstan, rector clean