Accept localhost dynamic-port loopback redirect_uris - #6215
Open
jhrozek wants to merge 1 commit into
Open
Conversation
Native MCP clients registered via DCR (VS Code, Claude Code) register a portless loopback redirect_uri such as http://localhost/callback and then listen on an ephemeral port. RFC 8252 Section 7.3 requires the authorization server to allow any port for loopback redirects, but fosite's matcher recognises only IP literals (127.0.0.1, [::1]) -- it compares "localhost" by exact string equality, so the dynamic-port request is rejected and the flow cannot complete. /authorize now rewrites the request form's redirect_uri to the client's registered portless literal so fosite's exact-match validation accepts it, then restores the requested dynamic-port URI for the pending authorization. The authorization code stays bound to the exact port the client asked for, so the token-endpoint check is unaffected. Error paths keep the client's real listener as the redirect target. A wrapper supplies the dynamic-port URI to fosite's error writer and widens its redirect_uri validity check to cover the localhost loopback case it cannot recognise on its own. The wrapper only ever widens that answer, never narrows it, so a client whose redirect_uri fosite would have accepted unaided still receives a proper error redirect rather than a bare JSON body. Loopback matching is stricter than fosite's: escaped paths are compared literally, a bare "?" is significant, and fragments and userinfo are rejected. Exact registered matches take precedence over dynamic-port matches so a client that pinned a port is never rewritten to a different registered entry. Dynamic-port matching is restricted to public clients, since RFC 8252 loopback redirects are a native-app pattern. IP-literal loopback clients are left untouched, as fosite already matches those natively on both success and error paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhrozek
requested review from
ChrisJBurns,
JAORMX,
rdimitrov and
tgrunnagle
as code owners
August 5, 2026 16:39
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6215 +/- ##
==========================================
- Coverage 72.63% 72.44% -0.19%
==========================================
Files 736 739 +3
Lines 76355 76748 +393
==========================================
+ Hits 55463 55603 +140
- Misses 16955 17179 +224
- Partials 3937 3966 +29 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
redirect_urilikehttp://localhost/callback, then listen on an ephemeral port. RFC 8252 §7.3 requires the authorization server to allow any port for loopback redirects, but fosite's matcher recognises only IP literals (127.0.0.1,[::1]) —localhostis compared by exact string equality, sohttp://localhost:54321/callbackis rejected and the flow can never complete./authorizerewrites the form'sredirect_urito the client's registered portless literal so fosite's exact-match validation accepts it, then restores the requested dynamic-port URI for the pending authorization. The code stays bound to the exact requested port, so the/tokencheck is unchanged.redirect_urivalidity check to cover thelocalhostloopback case it cannot recognise. The wrapper only ever widens that answer, never narrows it — so a client whoseredirect_urifosite would have accepted unaided still gets a proper error redirect instead of a bare JSON body.?is significant, fragments and userinfo rejected. Exact registered matches take precedence over dynamic-port matches, so a client that pinned a port is never rewritten to a different registered entry. Dynamic-port matching is restricted to public clients.Fixes #6189
Type of change
Test plan
task test)task lint-fix)New and updated coverage:
/authorize→ callback →/tokenasserting the dynamic port survives all three legs, and that a different port and a different path are both rejected at token exchange./authorize(post-redirect_urivalidation failure) and callback (upstream IdP denies consent) land on the client's real dynamic port. Both assert the fullhost:portprefix — a substring check would have passed before the fix too.redirect_urimust stay valid (this last one fails if the override ever narrows again).ForceQuery, fragment and userinfo rejection, case-insensitiveLOCALHOST, confidential-client rejection.Changes
handlers/authorize.goredirect_urirewrite;loopbackAuthorizeRequesterwrapper for error redirects; warn on a failed client lookuphandlers/callback.goregistration/client.goRegisteredLoopbackRedirectURIfree function replacing a single-method interface; exact-match precedence; escaped-path/ForceQuery/fragment/userinfo hardening; behaviourlessLoopbackClientdeletedstorage/redis.gofosite.Clientstorage/cimd_decorator.goserver/doc.goDoes this introduce a user-facing change?
Yes. Native OAuth clients that register a portless
localhostloopbackredirect_uriand listen on an ephemeral port can now complete the authorization flow against the embedded auth server. Previously these requests were rejected withredirect_urimismatch. OAuth errors now also reach such a client's real listener rather than being delivered to whatever holds port 80 (or not delivered at all).Special notes for reviewers
MatchRedirectURIWithClientRedirectURIsnever consults aClient's own matcher, and its loopback exception is IP-literal-only. Rewriting the form into its exact-match branch is the only seam available without forking fosite.G11NContext, so error messages fall back tolanguage.English. Harmless here — noMessageCatalogis configured — but it is a real difference from an unwrapped requester.redirect_urithat omitsredirect_uriat/authorizegetsinvalid_grantat/token, because fosite's defaulted value is recorded into the pending authorization and then demanded back. Present onmain; only reachable for non-OIDC scope sets, since fosite rejects an omittedredirect_uriwheneveropenidis requested.http://127.0.0.1/cb%2Fchildcan have a code delivered to/cb/child. Mandatory S256 PKCE bounds the impact, and closing it means either extending the rewrite to IP literals (losing fosite's correct error-path port handling) or a reject-only pre-check. Out of scope here.🤖 Generated with Claude Code