fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds (#2225)

* fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds

Cursor (IDE, CLI, and Grok Bot on top of it) registers three redirect URIs
in one /register request: cursor://anysphere.cursor-mcp/oauth/callback,
https://www.cursor.com/agents/mcp/oauth/callback and
http://localhost:8787/callback. Only the loopback matched a built-in
pattern and /register fails the whole set on any unknown URI, so every
Cursor connection to the URL we hand out in Settings died with
"Redirect URI not allowed". Users cannot self-register the cursor://
form either (the settings panel requires https).

Add a built-in `cursor` provider with the two non-loopback callbacks as
exact matches (no cursor.com prefix), name it "Cursor (Anysphere)" on
the consent page, list the pre-approved clients in the OAuth clients
settings text (sv + en) and the mcp-server rules, and cover the
register, allowlist and consent paths with tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBFeTvQXgMCNXR6fG9drff

* fix(mcp-oauth): show the cursor:// deeplink unverified and let CSP pass its post-consent redirect

Review findings on #2225, one pass:

- Skeptic (correctness), REFUTED: new URL('cursor://...').origin is the
  string "null", so the consent page emitted form-action 'self' null and
  Chromium would block the 303 to the deeplink after Allow. The header
  now uses the scheme-source (cursor:) when the origin is opaque; a test
  pins the header on the cursor:// URI.
- Skeptic (security), CodeRabbit (Major) and Superagent (P2): a custom
  scheme can be claimed by any local app (RFC 8252 section 8.4), so it
  must not be presented as a vendor-verified callback. The deeplink is
  its own provider, cursor_deeplink, rendered "Cursor (Anysphere)" with
  the localhost tag "Din egen dator" and verified: false. The https
  cursor.com callback keeps the verified label. A test pins that a code
  minted without a code_challenge can never be exchanged, which is what
  keeps a scheme hijack from turning into a token.
- CodeRabbit (Minor): the rules doc now says the Grok callback matches
  with or without the trailing slash.
- Regression skeptic: docs/WHITELABEL.md listed only Claude and
  localhost and pointed at the wrong file; now lists the built-ins and
  points at lib/auth/oauth-allowlist.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBFeTvQXgMCNXR6fG9drff

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 15:33:58 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent d900fea1a8
commit bc5da12372
11 changed files with 160 additions and 11 deletions
+15 -2
View File
@@ -828,12 +828,18 @@ export async function GET(request: Request) {
// form-action re-checks every hop in the redirect chain. With only 'self'
// the browser would block the post-consent redirect. The origin is safe
// to whitelist here because resolveRedirectUri() already gated it above.
const redirectOrigin = new URL(redirectUri).origin
//
// A custom-scheme callback (cursor://...) has the opaque origin "null",
// which CSP would read as a host literally named "null" and match nothing,
// so the post-consent 303 would be blocked in Chromium. A scheme-source
// (cursor:) is the only form CSP offers for such a URI.
const redirectUrl = new URL(redirectUri)
const formActionSource = redirectUrl.origin === 'null' ? redirectUrl.protocol : redirectUrl.origin
const csp = [
"default-src 'none'",
`script-src 'nonce-${cspNonce}'`,
"style-src 'unsafe-inline'",
`form-action 'self' ${redirectOrigin}`,
`form-action 'self' ${formActionSource}`,
"base-uri 'none'",
"frame-ancestors 'none'",
].join('; ')
@@ -1094,6 +1100,13 @@ function describeClient(
return { name: 'ChatGPT (OpenAI)', tag: 'Verifierad', verified: true }
case 'grok':
return { name: 'Grok (xAI)', tag: 'Verifierad', verified: true }
case 'cursor':
return { name: 'Cursor (Anysphere)', tag: 'Verifierad', verified: true }
case 'cursor_deeplink':
// A custom scheme can be claimed by any local app (RFC 8252 section
// 8.4), so it carries loopback trust, not vendor trust: same tag as
// localhost, never marked verified.
return { name: 'Cursor (Anysphere)', tag: 'Din egen dator', verified: false }
case 'local':
return { name: 'Lokal utveckling (localhost)', tag: 'Din egen dator', verified: false }
}