fix(coderd/oauth2provider): accept any registered redirect_uri, not only the first - #28914
fix(coderd/oauth2provider): accept any registered redirect_uri, not only the first#28914matifali wants to merge 2 commits into
Conversation
…nly the first Dynamic client registration stores every redirect_uris entry, but authorize and token validated redirect_uri only against callback_url, the first entry. Cursor registers a desktop deep link and a web callback and uses the second, so /oauth2/authorize answered 400. - Match the presented redirect_uri against redirect_uris; fall back to callback_url for API-created apps and unregistered values. - Require redirect_uri on /oauth2/authorize when several are registered (RFC 6749 3.1.2.3) and report misses against the registered set. - UpdateApp replaces redirect_uris when an admin changes the callback, so the edit revokes the previous targets now that the list is authoritative.
441602b to
9f5f260
Compare
Documentation CheckThis PR makes Updates Needed
New Documentation Needed
Automated review via Coder Agents |
|
Hi @matifali, thank you for preparing a fix for this issue! Just wanted to let you know that I’ve also prepared a fix in #29014, and those changes need to be built on top of another fix in #29013. If it’s okay with you, we could close this PR for now. I’ll keep you posted and update the ticket once #29014 is merged. Thanks! |
|
Thats perfect. I will close it. |
Problem
Cursor desktop cannot complete OAuth2 against the remote MCP server. Cursor registers two
redirect_urisin one DCR request (cursor://anysphere.cursor-mcp/oauth/callbackandhttps://www.cursor.com/agents/mcp/oauth/callback) and then authorizes with the second. Coder stored both but validatedredirect_urionly againstcallback_url, which isredirect_uris[0], so/oauth2/authorizereturned:app.RedirectUriswas written by registration and never read again.Fix
registeredRedirectURL(app, presented): if the presentedredirect_uriis inapp.RedirectUris, it becomes the URL the request must match exactly; otherwise fall back toapp.CallbackURLso unregistered URIs are rejected as before. Used byShowAuthorizePage,ProcessAuthorize, andTokens.redirect_urisauthoritative surfaced a gap:UpdateAppkept the list while changingcallback_url, so an admin edit would no longer revoke old targets on a DCR app. It now replaces the list with the new callback when the callback changes./oauth2/authorizerequiresredirect_uriwhen the client registered several. Defaulting to the primary would send the code to the desktop deep link when a web flow omits the parameter.redirect_uriis reported against the registered set rather than "must exactly match ", andlogCorruptCallbacklogs the URI that failed.redirect_uris, so their behavior is unchanged.Validation
TestOAuth2MultipleRegisteredRedirectURIsregisters Cursor's exact two URIs and covers: consent page (GET) and code issuance with the second URI;Locationpoints at the presented URI; unregistered URI rejected on GET, POST, and token exchange with the new message; omittedredirect_urirejected; exchange citing the other registered URI rejected (invalid_grant); admin callback edit revokes the replaced URI while the new one keeps working. Each fix was reverted in isolation to confirm the corresponding subtest fails.go test ./coderd/oauth2provider/... ./coderd/httpapi/...andgo test ./coderd -run TestOAuth2pass;golangci-lintclean.Verified end to end from Cursor desktop against a dev server on this branch (
--experiments=oauth2,mcp-server-http, DCR enabled,*.try.coder.apptunnel): Cursor registered both callbacks, the browser showed the consent page instead of the 400, and thecoderMCP server connected and answeredcoder_get_authenticated_user.Deep review summary
Five independent reviewers (Security, Contract, Edge Case, Test Auditor, Go Architect) on the first revision. All agreed exact matching was preserved with a single enforcement point, no open-redirect path, the code-to-URI binding was untouched, and the test was authentic. Findings addressed in the second commit:
UpdateAppreplacesredirect_uriswhen the callback changes; test/oauth2/authorizepath untestedredirect_uriwith several registered defaulted to primarylogCorruptCallbacklogged the wrong URLNot addressed here: the admin API/UI still exposes only
callback_url, notredirect_uris. Worth a follow-up so admins can see the full allowlist.Closes #28910
Related to #19581