fix: match OAuth2 redirect_uri against every registered redirect URI - #29014
Conversation
RFC 8252 §7.3 requires an authorization server to accept any port on a loopback redirect URI, because a native app binds an ephemeral port at runtime and cannot know it at registration. Coder compares redirect URIs by exact string equality with no such exception, so a public client that registers http://127.0.0.1/callback can never match at authorize time. This adds the comparison rule without wiring it in yet: - RedirectURIMatches returns true on exact string equality (OAuth 2.1 §2.3.1), or when the registered URI is http to a loopback host and the two URIs are equal with the port removed. Every other component, including query and userinfo, must still match. The exception is decided by the registered URI, so a client cannot opt in by presenting a loopback host the app never registered. - isLoopbackAddress is exported as IsLoopbackAddress so registration and comparison share one definition of loopback. Its one caller is updated. A follow-up commit swaps the comparison in httpapi.QueryParamParser.RedirectURL. No behavior changes in this commit. Part of PLAT-488.
RedirectURL rejected any redirect_uri that was not byte-for-byte equal to the registered one. RFC 8252 §7.3 requires the port of a loopback redirect URI to be accepted whatever the client bound at runtime, so a public client registered with http://127.0.0.1/callback could never pass this check. The comparison now goes through codersdk.RedirectURIMatches, which keeps exact matching for every URI except a registered http URI to a loopback host, where the port is ignored. Both callers, the authorize handlers and the token endpoint, pick up the change through this one function. The default when redirect_uri is absent, the unparsable-input path, and the error text are unchanged. The code-vs-token redirect_uri check in the token endpoint is unchanged too: RFC 6749 §4.1.3 requires those two values to be identical, and a client presents the same port at both steps. Part of PLAT-488.
…on end to end The comparator change in httpapi is exercised here through both handlers. A public client registers http://<host>/callback with no port and authorizes with port 53219 for 127.0.0.1, [::1], and localhost. The consent page's cancel link, the code redirect, and the token exchange all use the presented port. Two cases pin what the exception does not do. An exchange from a different port than the code was issued to is refused with invalid_grant (RFC 6749 §4.1.3), and the code stays redeemable from the right port. A code issued with no redirect_uri, which leaves nothing on the code to compare against, still exchanges from a loopback port, so the token endpoint's own registration check is covered on its own. Part of PLAT-488.
The OAuth2 provider page said redirect URIs must match exactly and listed exact matching among the OAuth 2.1 requirements Coder enforces. Both are now qualified: the port of a loopback http redirect URI is not compared, as RFC 8252 requires for native apps that choose a port at runtime. The loopback note under Client Authentication Methods says how to register such a URI, the "Invalid redirect_uri" troubleshooting entry names the exception and points at that note, and the Standards Compliance paragraph links RFC 8252. Paragraphs touched are reflowed to one sentence per line, per the docs style guide. Part of PLAT-488.
Docs previewCheck off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here. |
…IMatches comments
Dynamic client registration stored every redirect URI a client registered but only the first was ever compared against. A client that registered a desktop deep link and a web callback, as Cursor does, and presented the second was rejected at /oauth2/authorize with a 400. RFC 6749 §3.1.2.3 requires the presented URI to match one of the registered URIs. - RedirectURL takes the list of registered URIs and accepts a match against any entry. An absent redirect_uri still defaults to the primary callback. - registeredRedirectURIs builds that list from CallbackURL and RedirectUris together, since admin-created apps have an empty list and an admin edit rewrites CallbackURL without touching it. - Both authorize handlers and the token endpoint go through it. The scheme check runs on every registered entry before any match. - The rejection no longer echoes the registered URL. The code-vs-token redirect_uri check is unchanged: the value stored on the code is whichever registered entry matched, so the exchange is bound to it as before. Part of PLAT-582. Refs #28910.
…t the first registered A public client registers a desktop deep link and a web callback, in that order, and authorizes with the second. The consent page renders, the code goes to the second, and the exchange with the second succeeds. An exchange presenting the first is refused with invalid_grant although it is registered too: the code is bound to the entry that matched (RFC 6749 §4.1.3). An unregistered URI is still answered on Coder. RegisterPublicClient gains a variadic sibling for the two-URI registration. Part of PLAT-582.
Dynamic client registration accepts several redirect_uris, and a request may now present any of them. The page said the request had to match "the one registered", which was true of the behavior and wrong about the registration. The DCR section explains what the first entry is for: it is the callback the web UI shows and the destination when a request omits redirect_uri. The two troubleshooting entries are reworded to match. Part of PLAT-582.
e99656e to
cbfbb70
Compare
|
/coder-agents-review |
|
Chat: Review in progress (17/17 reviewers complete) | View chat deep-review v0.9.0 | Round 1 | Last posted: Round 1, 5 findings (2 P3, 3 Note), COMMENT. Review Finding inventoryFinding inventory - PR #29014Findings
Contested and acknowledgedNone. Round logRound 1Panel (16: Bisky, Hisoka, Mafu-san, Mafuuu, Pariston, Kurapika, Chopper, ging-go, Gon, Leorio, Ryosuke, Knov, Razor, Komugi + wildcards Meruem, Zoro). Netero first pass: P3 (orphaned comment) + Note (allowed[0]), mechanical floor clean, panel proceeded. No Law (effective additions 242 < 1000). Reviewed against e521650..cbfbb70. 2 P3, 3 Notes posted. 2 dropped. Event COMMENT (no P0-P1). Convergence: CRF-1 orphaned comment (6 reviewers). CRF-2 admin-edit re-arms RedirectUris (Pariston P3 sharpest; Hisoka/Mafu-san reached it from different angles; higher severity wins). CRF-3 allowed[0] panic (5 reviewers converged on Note; Knov and Meruem proposed the same structural fix: take the primary as its own parameter so non-emptiness is a type property). Drop/downgrade gate:
Verified positives (not findings): code-vs-token binding preserved through unchanged tokens.go:409 equality (Bisky, Hisoka, Kurapika, Knov, Razor, Ryosuke, Pariston, Chopper, Mafuuu); scheme loop cannot 500 on normally-registered apps because registration applies the same validator (Chopper, Pariston, Razor, Hisoka); slice.Unique preserves first-occurrence order so "primary first" holds (Mafu-san, Hisoka, Zoro). Mafuuu, Komugi, ging-go: no findings. Process: Leorio flagged the test commit subject (2fc732b) at 91 chars, past the 72-char limit. Raised in the review body, not as an inline comment. About deep-reviewCRF = Coder Review Finding (P0-P4, Nit, Note)
|
There was a problem hiding this comment.
Clean, well-tested fix. It makes the read path consult the RedirectUris the write path already stored, matching the presented redirect_uri against every registered entry as RFC 6749 §3.1.2.3 requires, and it does so at the right causal level: the redirect set is materialized once in registeredRedirectURIs and threaded as data, so authorize and token agree without duplicated parsing. The security binding is preserved and proven: the code stores whichever entry matched, and the unchanged code-vs-token equality at tokens.go:409 still refuses an exchange from a different registered entry (invalid_grant). The panel independently verified that the widened token-endpoint match does not weaken that binding, that the per-entry scheme loop cannot 500 on a normally-registered app (registration applies the same validator), and that allowed[0] is non-empty by construction today. Test density is real, not decoration: the end-to-end test presents the second URI, follows the 302 to that exact entry, then proves the exchange is refused with the first URI and accepted with the second. Bisky put it well: "One test, three real guarantees, no mock in sight."
Severity count: 2 P3, 3 Notes. No P0-P2.
Two items need a human decision rather than a silent ship:
- CRF-2: this PR makes
RedirectUrisload-bearing at authorize/token. The admin update path rewritesCallbackURLwhile keepingRedirectUris, and no admin endpoint exposesRedirectUrisfor removal, so an admin who edits the callback to move an app off a URI cannot remove the old one; it stays a valid authorization-code destination. Blast radius is bounded (same app, registration-validated, no cross-client interception), but the admin mental model breaks. File a ticket or explicitly accept the gap. - CRF-5: an omitted
redirect_uristill defaults to the primary when several are registered, which OAuth 2.1 §2.3.2 forbids. The PR body discloses this and attributes it to PLAT-582; it needs an explicit human sign-off that the deviation is accepted.
Process: the test commit subject (2fc732b) runs 91 characters, past the 72-char limit, and truncates at "the first registered", the exact point of the test. Consider tightening it, e.g. test(coderd/oauth2provider): authorize with a non-primary redirect URI.
🤖 This review was automatically generated with Coder Agents.
RedirectURL read allowed[0] under a precondition stated only in its doc comment. Splitting the primary callback from the alternates makes the slice indexing and the absent-param default true by construction, so no future caller can hand it an empty slice. registeredRedirectURIs returns the primary and the alternates separately and drops the primary from the alternates instead of deduplicating the combined list.
…ment back on the test appWithCallback had been inserted between the comment and the function it documents. The helper's own one-line comment restated its body and is dropped.
matifali
left a comment
There was a problem hiding this comment.
Tested with a dev build from this Branch and was able to successfully connect to Cursor. One caveat, though, was that I needed to enable DCR.
2026-09-09 18:26:58.251 [warning] [Shared MCP process] Transient error connecting to streamableHttp server: Incompatible auth server: does not support dynamic client registration
2026-09-09 18:26:58.251 [warning] [Shared MCP process] Connection failed: Incompatible auth server: does not support dynamic client registration
2026-09-09 18:26:58.251 [warning] [Shared MCP process] [V2 FSM] connection:connect_failure: conn=connecting,auth=unknown -> conn=failed,auth=unknown
Thanks for reviewing and verifying this PR, @matifali! Yes, DCR is disabled by default. Going forward, an admin will need to explicitly enable it before public clients can register with coderd. |
TL;DR
Cursor registers two redirect URIs in one registration, a desktop deep link and a web callback, and presents the second at
/oauth2/authorize. Coder stored both but compared against the first only, so the request was rejected with a 400 before a code was issued. The presented redirect_uri now matches any registered entry, as RFC 6749 §3.1.2.3 requires.Contract change
cursor://…/callback,https://www.cursor.com/…/callbackhttps://other.example/callbackWhere in the OAuth Flow
Diagram: Cursor presenting its second registered URI
sequenceDiagram participant C as Cursor participant S as coderd Note over C,S: registration already stores both URIs (unchanged) C->>S: GET /oauth2/authorize?redirect_uri=https://www.cursor.com/…/callback S->>S: registeredRedirectURIs: primary cursor://…, alternates [https://www.cursor.com/…] S->>S: RedirectURL matches an alternate S-->>C: 200 consent page C->>S: POST /oauth2/authorize (Allow) S->>S: code stores redirect_uri = https://www.cursor.com/… S-->>C: 302 https://www.cursor.com/…/callback?code=…&state=… C->>S: POST /oauth2/tokens redirect_uri=https://www.cursor.com/… S->>S: registration match, then code-vs-token equality (unchanged), then PKCE S-->>C: 200 tokensWhat it satisfies
Docs. The Dynamic Client Registration section says several redirect_uris are honoured and what the first entry is for. The two troubleshooting entries say "one of the redirect URIs registered" instead of "the one registered".
PLAT-582. Fixes #28910. Stacked on #29013 (PLAT-488). Follow-ups from review: PLAT-619, PLAT-620.
Manual Tests
Scenario summary, 21 run
Ris the registered list,Pthe presentedredirect_uri.firstandsecondare Cursor's two entries,cursor://anysphere.cursor-mcp/oauth/callbackandhttps://www.cursor.com/agents/mcp/oauth/callback.[first, second], Psecond: consent page renders, cancel link atwww.cursor.comwww.cursor.com/agents/mcp/oauth/callbackwith code and state; code row storessecondsecond, no client secret: 200firstend to end: 200, 302 tocursor://, 200redirect_uriabsent: 302 tofirst(the primary), code row NULLhttps://not-registered.example/callback: 400 on Coder, no Location,must match one of the application's registered redirect URIs; neither registered URI in the pageinvalid_requestJSON, no Location[web, loopback, alt], P each entry: 200 ×3; consent posted onalt: 302 toalt.example.com, code rowalthttp://127.0.0.1:53219/callbackagainst the port-less loopback entry at index 1: 200, 302 to:53219, exchange 200https://alt.example.com:8443/callback, thenhttps://web.example.com/other: 400 ×2, nothing but the loopback port is exceptedsecond, exchanged withfirst: 400invalid_grant, code survives; retry withsecond: 200first, exchanged withsecond: 400invalid_grant; retry withfirst: 200second, exchanged with an unregistered URI: 400invalid_request, code survives; retry: 200redirect_uri, exchanged citingsecond: 200 (registration check alone decides)[one, two]: RFC 7592 GET shows both, admin API showsoneascallback_urland has no list field, DB row has both columns[two, three]: DBcallback_urltwo, listtwo, three; Pone400, Pthree200, absent 302 totwo[edit/callback, edit/alt], admin PUT sets callbacknew.example.com: all three P answer 200, absent 302 tonew; the edit cannot remove an entry[app/callback, app/alt], Papp/alt, exchange with secret: 200, 302, 200https://admin.example.com/callback(empty list), P identical: 200, 302, 200https://admin.example.com/other: 400, the registered URL is no longer echoedNot run, by decision after 5c: 5d (duplicate entry at registration), 6 (an unparsable or
javascript:entry planted by SQL, the 500 paths), 7 (Cursor desktop end to end), 8 (docs grep), 9 (control on the pre-fix branch). The unit tests cover 5d and 6a; 2e and 2f are the composition with #29013 that no test covers.Shell helpers used throughout
Run on the laptop;
$WSis the workspace, reached over ssh for the DB and the session token.Fixtures. Five registered through
POST /oauth2/register(public ones withtoken_endpoint_auth_method: none), one throughPOST /api/v2/oauth2-provider/apps. Registration stores the first entry ascallback_urland the whole list asredirect_uris; the admin-created app hasredirect_uris = '{}'.1. Baseline: Cursor's shape (the #28910 report)
The request #28910 reported as a 400 renders the consent page, the code goes to the second entry and is stored as it, and the exchange with the second entry and no secret succeeds. The first entry still works end to end. Run note: the first 1c attempt used a code minted 30 minutes earlier and got
invalid_grant; codes live 10 minutes. The exchange was rerun with a fresh code.2. The list semantics
An absent
redirect_uristill defaults to the primary and stores nothing on the code. An unregistered URI is refused on Coder from GET and POST alike, and the page names no URI, registered or presented (the onlycursormatch in the page is the CSScursor: pointer). All three entries of a three-entry list are compared, the last as good as the first. The loopback port exception from #29013 applies to an entry at index 1 behind anhttpsprimary that would not earn it, and nothing else is excepted on any entry.3. The token endpoint: the exchange is bound to the entry the code went to
Two checks, two error codes. A registered entry other than the one the code went to passes the registration check and is refused by the unchanged code-vs-request equality as
invalid_grant, in both directions; the primary is bound like any other entry. An unregistered URI fails the registration check first asinvalid_request. Every refusal happens before the code is consumed, so the retry succeeds. When the authorize request omittedredirect_urithere is nothing on the code row to compare, so the registration check alone decides andsecondpasses: the one case where delivery URI and exchange URI may differ. Both are the same client's registered callbacks; OAuth 2.1 §2.3.2 would close it by requiring the parameter, tracked as PLAT-620.4. Registration edits move the list
Three views of one registration: RFC 7592 shows the list, the admin API shows
callback_urlalone and has no field that could carry the list, the DB has both columns. An RFC 7592 update replaces both columns and the next request sees the new list with nothing cached: the dropped entry is refused, the added one accepted, and the absent-parameter default follows the new primary. The adminPUTrewritescallback_urlonly, so the callback becomes a value outside the list and all three URIs are honoured; the edit cannot remove an entry and the admin view shows only the new one. That is PLAT-619.5. Client type and regression controls
Client type does not enter the redirect URI rule; the secret is the only difference. The admin-created app, one callback and an empty list, goes through the rewritten functions on the pre-PR single-URI path unchanged. Its refusal names the rule instead of the registered URL.