Accept localhost dynamic-port loopback redirect_uris - #6215
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6215 +/- ##
==========================================
+ Coverage 72.98% 73.06% +0.08%
==========================================
Files 742 745 +3
Lines 78398 78787 +389
==========================================
+ Hits 57216 57566 +350
+ Misses 17193 17191 -2
- Partials 3989 4030 +41 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
#6189 reporter here — this fix is what our deployment is waiting on (Claude Code is blocked at What I checked, and found sound:
One heads-up: the branch now shows a conflict with We run this in production (Redis-backed embedded auth server, CIMD enabled) and will gladly verify the fix end to end with the real Claude Code client as soon as it ships in a release, and report back. Anything we can do to help move the review forward? |
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>
a899108 to
82fb0f5
Compare
The main-branch merge replaced Config.Public (bool) with TokenEndpointAuthMethod (string) and dropped the LoopbackClient wrapper type; these two non-conflicted test files still referenced the old shapes and failed to build.
82fb0f5 to
3b632c6
Compare
samuv
left a comment
There was a problem hiding this comment.
Thanks for the careful work here. The matcher hardening and end-to-end coverage are strong, and the current CI suite is green. I found three issues worth addressing: later /authorize failures can still lose the dynamic port, unknown clients can generate unauthenticated WARN traffic, and the loopback matcher can ignore userinfo on the registered URI. I also left one documentation nit for the deleted wrapper.
Checklist
- Tests: Strong coverage overall; please add a regression for a post-validation failure such as
StorePendingAuthorization. - Docs: One stale
LoopbackClientcomment remains. - Registry impact: None.
- Security: Please tighten the registered-URI userinfo check and avoid WARN amplification for normal unknown-client input.
- Backwards compatibility: Existing IP-literal handling appears preserved; the new localhost behavior is otherwise scoped to public clients.
Three loopback-redirect fixes from review: a failure after NewAuthorizeRequest succeeds (storage outage, upstream URL build failure) redirected to the registered portless literal instead of the client's real listener; an unknown client_id on the unauthenticated /oauth/authorize endpoint logged at Warn, making log flooding trivial; and the loopback matcher checked the requested URI for userinfo but not the registered one, so a registered URI carrying userinfo could still match. Also updates a stale comment describing the removed LoopbackClient wrapper.
samuv
left a comment
There was a problem hiding this comment.
Thanks for turning these around. The dynamic-port error handling, registered-userinfo check, and stale comment are fixed with focused tests, and CI is green. I could not approve yet because the WARN classification still misses CIMD lookup failures, so the unauthenticated log-flooding case remains for HTTPS client IDs. Also, the PR commits do not include the Signed-off-by trailer required by CONTRIBUTING.md.
Checklist
- Tests: The new regressions cover post-validation redirects and storage not-found logging; please add the CIMD/Fosite not-found case.
- Docs: The stale
LoopbackClientcomment is fixed. - Registry impact: None.
- Security: One warning-amplification path remains in CIMD mode.
- Backwards compatibility: No regression found in the follow-up changes.
samuv's follow-up: an opaque DCR client_id wraps storage.ErrNotFound on miss, but a CIMD client_id (an https:// URL resolved live via CIMDStorageDecorator.fetch) wraps fosite.ErrNotFound instead, so a bogus or unreachable CIMD URL still logged at Warn on this unauthenticated endpoint. Signed-off-by: Jakub Hrozek <jakub@stacklok.com>
|
Thanks for catching the CIMD gap. Fixed in 186a788 — On the |
samuv
left a comment
There was a problem hiding this comment.
Thanks for addressing the feedback. The requester wrapping now covers the full authorization error path, registered redirect URIs reject userinfo before loopback matching, and expected not-found cases from both storage and CIMD are logged quietly. The focused regression tests cover the reported cases, and all checks are green. Looks good to me.
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