diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..95c1d03 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/matt/Documents/Github/cloudflare-mcp-log-dcr/node_modules \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 5af9b7c..b1d9c44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,13 @@ const oauthProvider = new OAuthProvider({ 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) => diff --git a/tests/auth/cimd.test.ts b/tests/auth/cimd.test.ts index a794105..1a1e753 100644 --- a/tests/auth/cimd.test.ts +++ b/tests/auth/cimd.test.ts @@ -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))) ) @@ -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) }) diff --git a/tests/auth/private-use-redirects.test.ts b/tests/auth/private-use-redirects.test.ts new file mode 100644 index 0000000..33a891f --- /dev/null +++ b/tests/auth/private-use-redirects.test.ts @@ -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 { + 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 { + 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' }) + }) +})