* 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>
137 lines
4.6 KiB
TypeScript
137 lines
4.6 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { POST } from '../route'
|
|
|
|
function createRequest(body: unknown) {
|
|
return new Request('http://localhost/api/mcp-oauth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
describe('POST /api/mcp-oauth/register', () => {
|
|
it('returns 400 for invalid JSON', async () => {
|
|
const request = new Request('http://localhost/api/mcp-oauth/register', {
|
|
method: 'POST',
|
|
body: 'not json',
|
|
})
|
|
const response = await POST(request)
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('accepts registration with valid claude.ai redirect_uris', async () => {
|
|
const response = await POST(createRequest({
|
|
client_name: 'Test Client',
|
|
redirect_uris: ['https://claude.ai/api/oauth/callback'],
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
const body = await response.json()
|
|
expect(body.client_id).toBeDefined()
|
|
expect(body.redirect_uris).toEqual(['https://claude.ai/api/oauth/callback'])
|
|
})
|
|
|
|
it('accepts registration with localhost redirect_uris', async () => {
|
|
const response = await POST(createRequest({
|
|
redirect_uris: ['http://localhost:3000/callback'],
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
})
|
|
|
|
it('accepts registration with 127.0.0.1 redirect_uris', async () => {
|
|
const response = await POST(createRequest({
|
|
redirect_uris: ['http://127.0.0.1:8080/callback'],
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
})
|
|
|
|
it('accepts registration with claude.com redirect_uris', async () => {
|
|
const response = await POST(createRequest({
|
|
redirect_uris: ['https://claude.com/api/oauth/callback'],
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
})
|
|
|
|
it('accepts registration with the grok.com connector callback', async () => {
|
|
const response = await POST(createRequest({
|
|
client_name: 'Grok',
|
|
redirect_uris: ['https://grok.com/connectors-oauth-exchange-code/'],
|
|
token_endpoint_auth_method: 'none',
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
const body = await response.json()
|
|
expect(body.redirect_uris).toEqual(['https://grok.com/connectors-oauth-exchange-code/'])
|
|
expect(body.token_endpoint_auth_method).toBe('none')
|
|
})
|
|
|
|
it('accepts the three redirect_uris Cursor registers in one request', async () => {
|
|
// Cursor sends the legacy deeplink, the Cloud Agents web fallback and the
|
|
// RFC 8252 loopback together; one unknown URI used to fail the whole set.
|
|
const uris = [
|
|
'cursor://anysphere.cursor-mcp/oauth/callback',
|
|
'https://www.cursor.com/agents/mcp/oauth/callback',
|
|
'http://localhost:8787/callback',
|
|
]
|
|
const response = await POST(createRequest({
|
|
client_name: 'Cursor',
|
|
redirect_uris: uris,
|
|
token_endpoint_auth_method: 'none',
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
const body = await response.json()
|
|
expect(body.redirect_uris).toEqual(uris)
|
|
})
|
|
|
|
it('rejects other cursor.com paths and other cursor:// authorities', async () => {
|
|
for (const uri of [
|
|
'https://www.cursor.com/agents/mcp/oauth/callback2',
|
|
'https://cursor.com/agents/mcp/oauth/callback',
|
|
'cursor://evil.extension/oauth/callback',
|
|
]) {
|
|
const response = await POST(createRequest({ redirect_uris: [uri] }))
|
|
expect(response.status, uri).toBe(400)
|
|
}
|
|
})
|
|
|
|
it('rejects other grok.com paths', async () => {
|
|
const response = await POST(createRequest({
|
|
redirect_uris: ['https://grok.com/oauth/callback'],
|
|
}))
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('rejects registration with disallowed redirect_uris', async () => {
|
|
const response = await POST(createRequest({
|
|
redirect_uris: ['https://evil.com/callback'],
|
|
}))
|
|
expect(response.status).toBe(400)
|
|
const body = await response.json()
|
|
expect(body.error).toBe('invalid_redirect_uri')
|
|
})
|
|
|
|
it('rejects if any redirect_uri in array is invalid', async () => {
|
|
const response = await POST(createRequest({
|
|
redirect_uris: [
|
|
'https://claude.ai/api/callback',
|
|
'https://evil.com/steal',
|
|
],
|
|
}))
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it('accepts registration with no redirect_uris', async () => {
|
|
const response = await POST(createRequest({
|
|
client_name: 'No URIs',
|
|
}))
|
|
expect(response.status).toBe(201)
|
|
const body = await response.json()
|
|
expect(body.redirect_uris).toEqual([])
|
|
})
|
|
|
|
it('defaults client_name to MCP Client', async () => {
|
|
const response = await POST(createRequest({}))
|
|
expect(response.status).toBe(201)
|
|
const body = await response.json()
|
|
expect(body.client_name).toBe('MCP Client')
|
|
})
|
|
})
|