Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions node_modules
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ const oauthProvider = new OAuthProvider<Env>({
tokenEndpoint: '/token',
clientRegistrationEndpoint: '/register',
clientIdMetadataDocumentEnabled: true,
// TODO(November 2026): remove once people have upgraded Cursor. Some Cursor versions register
// cursor://anysphere.cursor-mcp/oauth/callback next to their https and loopback callbacks, and
// workers-oauth-provider 1.2 refuses the whole registration because of it; 0.10 accepted it and
// Cursor signed in through the loopback callback. Our own /authorize check
// (isAllowedOAuthRedirectUri) still refuses a request that uses a custom scheme, and remote http
// stays refused at registration.
allowPrivateUseRedirectUris: true,
resolveExternalToken,
// An upstream invalid_grant thrown here revokes the grant (workers-oauth-provider 1.x).
tokenExchangeCallback: (options) =>
Expand Down
14 changes: 8 additions & 6 deletions tests/auth/cimd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,13 @@ describe('Client ID Metadata Documents', () => {
})

it.each([
'http://remote-client.example/callback',
'ftp://remote-client.example/callback',
'com.example.client:/callback'
])('rejects a non-HTTPS, non-loopback redirect URI locally: %s', async (redirectUri) => {
// workers-oauth-provider refuses remote http itself, before our own check.
['http://remote-client.example/callback', 'Invalid redirect URI'],
// Custom schemes pass the provider (allowPrivateUseRedirectUris, for Cursor) and are refused
// by our own isAllowedOAuthRedirectUri.
['ftp://remote-client.example/callback', 'Redirect URI must use HTTPS or a local loopback address'],
['com.example.client:/callback', 'Redirect URI must use HTTPS or a local loopback address']
])('rejects a non-HTTPS, non-loopback redirect URI locally: %s', async (redirectUri, message) => {
server.use(
http.get(CIMD_CLIENT_ID, () => HttpResponse.json(cimdMetadata(CIMD_CLIENT_ID, redirectUri)))
)
Expand All @@ -197,8 +200,7 @@ describe('Client ID Metadata Documents', () => {

expect(response.status).toBe(400)
expect(response.headers.get('location')).toBeNull()
// workers-oauth-provider 1.2 refuses the request's redirect URI itself, before our own check.
expect(await response.text()).toContain('Invalid redirect URI')
expect(await response.text()).toContain(message)
expect((await env.OAUTH_KV.list({ prefix: 'grant:' })).keys).toHaveLength(0)
})

Expand Down
78 changes: 78 additions & 0 deletions tests/auth/private-use-redirects.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { env, exports } from 'cloudflare:workers'
import { afterEach, describe, expect, it } from 'vitest'
import { clearKv } from '../helpers/kv'

const MCP_ORIGIN = 'https://mcp.cloudflare.com'
const DOWNSTREAM_CODE_CHALLENGE = 'I4fhllfHqqQsgap17V2SDI0scSei8H7U0e0rZBDIcbo'

// What some Cursor versions register (seen in production): a private-use callback next to
// https and loopback ones.
const CURSOR_REDIRECT_URIS = [
'cursor://anysphere.cursor-mcp/oauth/callback',
'https://www.cursor.com/agents/mcp/oauth/callback',
'http://localhost:8787/callback'
]

function register(redirectUris: string[]): Promise<Response> {
return exports.default.fetch(
new Request(`${MCP_ORIGIN}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Cursor',
redirect_uris: redirectUris,
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none'
})
})
)
}

function authorize(clientId: string, redirectUri: string): Promise<Response> {
const url = new URL(`${MCP_ORIGIN}/authorize`)
url.search = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
resource: `${MCP_ORIGIN}/mcp`,
scope: 'user:read',
state: 'client-state',
code_challenge: DOWNSTREAM_CODE_CHALLENGE,
code_challenge_method: 'S256'
}).toString()
return exports.default.fetch(new Request(url), { redirect: 'manual' })
}

describe('private-use redirect URIs (Cursor)', () => {
afterEach(async () => {
await clearKv(env.OAUTH_KV)
})

it("accepts Cursor's registration and signs in through its loopback callback", async () => {
const registration = await register(CURSOR_REDIRECT_URIS)
expect(registration.status).toBe(201)
const { client_id: clientId } = (await registration.json()) as { client_id: string }

const consent = await authorize(clientId, 'http://localhost:8787/callback')
expect(consent.status).toBe(200)
expect(await consent.text()).toContain('name="handle"')
})

it('still refuses an authorization that uses the private-use callback, without redirecting', async () => {
const registration = await register(CURSOR_REDIRECT_URIS)
const { client_id: clientId } = (await registration.json()) as { client_id: string }

const refused = await authorize(clientId, 'cursor://anysphere.cursor-mcp/oauth/callback')
expect(refused.status).toBe(400)
expect(refused.headers.get('location')).toBeNull()
expect(await refused.text()).toContain('Redirect URI must use HTTPS or a local loopback address')
expect((await env.OAUTH_KV.list({ prefix: 'grant:' })).keys).toHaveLength(0)
})

it('still refuses a registration with a remote http callback', async () => {
const registration = await register(['http://remote.example/callback'])
expect(registration.status).toBe(400)
await expect(registration.json()).resolves.toMatchObject({ error: 'invalid_client_metadata' })
})
})
Loading