|
| 1 | +/** @vitest-environment node */ |
| 2 | +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' |
| 3 | +import { NextRequest } from 'next/server' |
| 4 | +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' |
| 5 | + |
| 6 | +const mocks = vi.hoisted(() => ({ register: vi.fn(), rateLimit: vi.fn() })) |
| 7 | +vi.mock('better-auth/next-js', () => ({ toNextJsHandler: () => ({ POST: mocks.register }) })) |
| 8 | +vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit })) |
| 9 | +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) |
| 10 | + |
| 11 | +import { POST } from '@/app/api/auth/oauth2/register/route' |
| 12 | + |
| 13 | +const client = { |
| 14 | + client_name: 'Test MCP client', |
| 15 | + redirect_uris: ['http://127.0.0.1:43123/callback'], |
| 16 | +} |
| 17 | +function request(body: object = client, headers: Record<string, string> = {}) { |
| 18 | + return new NextRequest('https://sim.test/api/auth/oauth2/register', { |
| 19 | + method: 'POST', |
| 20 | + headers: { 'Content-Type': 'application/json', ...headers }, |
| 21 | + body: JSON.stringify(body), |
| 22 | + }) |
| 23 | +} |
| 24 | + |
| 25 | +afterAll(resetEnvFlagsMock) |
| 26 | +beforeEach(() => { |
| 27 | + vi.clearAllMocks() |
| 28 | + setEnvFlags({ isAuthDisabled: false }) |
| 29 | + mocks.rateLimit.mockResolvedValue(null) |
| 30 | + mocks.register.mockImplementation(async (req: Request) => |
| 31 | + Response.json( |
| 32 | + { |
| 33 | + ...(await req.clone().json()), |
| 34 | + client_id: 'client-1', |
| 35 | + client_id_issued_at: 1788000000, |
| 36 | + }, |
| 37 | + { status: 201 } |
| 38 | + ) |
| 39 | + ) |
| 40 | +}) |
| 41 | + |
| 42 | +describe('MCP public client registration', () => { |
| 43 | + it('registers a bounded public Search client without ambient credentials or privileged metadata', async () => { |
| 44 | + const response = await POST( |
| 45 | + request( |
| 46 | + { ...client, skip_consent: true, require_pkce: false, metadata: { elevated: true } }, |
| 47 | + { |
| 48 | + Cookie: 'session=private', |
| 49 | + Authorization: 'Bearer private', |
| 50 | + 'x-forwarded-for': '203.0.113.10', |
| 51 | + } |
| 52 | + ) |
| 53 | + ) |
| 54 | + expect(response.status).toBe(201) |
| 55 | + expect(await response.json()).toMatchObject({ |
| 56 | + ...client, |
| 57 | + client_id: 'client-1', |
| 58 | + token_endpoint_auth_method: 'none', |
| 59 | + scope: 'search:read offline_access', |
| 60 | + grant_types: ['authorization_code', 'refresh_token'], |
| 61 | + response_types: ['code'], |
| 62 | + }) |
| 63 | + const forwarded: Request = mocks.register.mock.calls[0][0] |
| 64 | + expect(forwarded.headers.has('cookie')).toBe(false) |
| 65 | + expect(forwarded.headers.has('authorization')).toBe(false) |
| 66 | + expect(forwarded.headers.get('x-forwarded-for')).toBe('203.0.113.10') |
| 67 | + expect(response.headers.get('cache-control')).toBe('no-store') |
| 68 | + }) |
| 69 | + |
| 70 | + it('returns only registered Search scopes when clients request all issuer scopes', async () => { |
| 71 | + const response = await POST( |
| 72 | + request({ ...client, scope: 'offline_access api:read api:write search:read' }) |
| 73 | + ) |
| 74 | + expect(response.status).toBe(201) |
| 75 | + expect(await response.json()).toMatchObject({ scope: 'search:read offline_access' }) |
| 76 | + const forwarded: Request = mocks.register.mock.calls[0][0] |
| 77 | + expect(await forwarded.json()).toMatchObject({ |
| 78 | + scope: 'search:read offline_access', |
| 79 | + require_pkce: true, |
| 80 | + }) |
| 81 | + }) |
| 82 | + |
| 83 | + it('registers Cursor browser and native callbacks together with PKCE required', async () => { |
| 84 | + const redirectUris = [ |
| 85 | + 'cursor://anysphere.cursor-mcp/oauth/callback', |
| 86 | + 'https://www.cursor.com/agents/mcp/oauth/callback', |
| 87 | + 'http://localhost:8787/callback', |
| 88 | + ] |
| 89 | + const response = await POST(request({ client_name: 'Cursor', redirect_uris: redirectUris })) |
| 90 | + expect(response.status).toBe(201) |
| 91 | + expect(await response.json()).toMatchObject({ redirect_uris: redirectUris }) |
| 92 | + const forwarded: Request = mocks.register.mock.calls[0][0] |
| 93 | + expect(await forwarded.json()).toMatchObject({ |
| 94 | + redirect_uris: redirectUris, |
| 95 | + require_pkce: true, |
| 96 | + token_endpoint_auth_method: 'none', |
| 97 | + }) |
| 98 | + }) |
| 99 | + |
| 100 | + it.each([ |
| 101 | + { ...client, scope: 'api:write' }, |
| 102 | + { ...client, token_endpoint_auth_method: 'client_secret_post' }, |
| 103 | + { ...client, grant_types: ['client_credentials'] }, |
| 104 | + { ...client, redirect_uris: ['http://evil.example/callback'] }, |
| 105 | + { ...client, redirect_uris: ['https://*.example/callback'] }, |
| 106 | + { ...client, redirect_uris: ['https://example.com/callback#fragment'] }, |
| 107 | + { ...client, redirect_uris: ['https://user:password@example.com/callback'] }, |
| 108 | + { ...client, redirect_uris: ['cursor://anysphere.cursor-mcp/other'] }, |
| 109 | + { ...client, redirect_uris: ['cursor://anysphere.cursor-mcp/oauth/callback?target=other'] }, |
| 110 | + { ...client, redirect_uris: ['cursor://other/oauth/callback'] }, |
| 111 | + { ...client, redirect_uris: ['javascript:alert(1)'] }, |
| 112 | + { ...client, redirect_uris: ['file:///oauth/callback'] }, |
| 113 | + { ...client, redirect_uris: ['data:text/html,callback'] }, |
| 114 | + { ...client, redirect_uris: ['unknown-app://oauth/callback'] }, |
| 115 | + { ...client, redirect_uris: Array(11).fill('https://example.com/callback') }, |
| 116 | + { ...client, client_name: 'a'.repeat(129) }, |
| 117 | + ])('rejects unsupported or unsafe client metadata: %o', async (body) => { |
| 118 | + expect((await POST(request(body))).status).toBe(400) |
| 119 | + expect(mocks.register).not.toHaveBeenCalled() |
| 120 | + }) |
| 121 | + |
| 122 | + it('admits before reading metadata or creating a client', async () => { |
| 123 | + mocks.rateLimit.mockResolvedValue(Response.json({ error: 'Rate limited' }, { status: 429 })) |
| 124 | + expect((await POST(request())).status).toBe(429) |
| 125 | + expect(mocks.register).not.toHaveBeenCalled() |
| 126 | + }) |
| 127 | + |
| 128 | + it('does not enable OAuth in auth-disabled deployments', async () => { |
| 129 | + setEnvFlags({ isAuthDisabled: true }) |
| 130 | + expect((await POST(request())).status).toBe(404) |
| 131 | + expect(mocks.register).not.toHaveBeenCalled() |
| 132 | + }) |
| 133 | +}) |
0 commit comments