Skip to content

Commit afe3d32

Browse files
authored
fix(mcp): pass SSRF-guarded fetch into OAuth callback token exchange (#5399)
Mirrors the same wiring already used by probe.ts and revoke.ts, so the callback's token-exchange request goes through the same guarded fetch as the rest of the OAuth flow.
1 parent 1574c20 commit afe3d32

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
authMockFns,
6+
dbChainMock,
7+
dbChainMockFns,
8+
mcpOauthMock,
9+
mcpOauthMockFns,
10+
resetDbChainMock,
11+
schemaMock,
12+
} from '@sim/testing'
13+
import { NextRequest } from 'next/server'
14+
import { beforeEach, describe, expect, it, vi } from 'vitest'
15+
16+
const { mockMcpAuth, mockCreateSsrfGuardedMcpFetch, mockGuardedFetch, mockDiscoverServerTools } =
17+
vi.hoisted(() => ({
18+
mockMcpAuth: vi.fn(),
19+
mockCreateSsrfGuardedMcpFetch: vi.fn(),
20+
mockGuardedFetch: vi.fn(),
21+
mockDiscoverServerTools: vi.fn(),
22+
}))
23+
24+
vi.mock('@sim/db', () => dbChainMock)
25+
vi.mock('@sim/db/schema', () => schemaMock)
26+
vi.mock('drizzle-orm', () => ({
27+
and: vi.fn(),
28+
eq: vi.fn(),
29+
isNull: vi.fn(),
30+
}))
31+
vi.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
32+
auth: mockMcpAuth,
33+
}))
34+
vi.mock('@/lib/mcp/oauth', () => mcpOauthMock)
35+
vi.mock('@/lib/mcp/pinned-fetch', () => ({
36+
createSsrfGuardedMcpFetch: mockCreateSsrfGuardedMcpFetch,
37+
}))
38+
vi.mock('@/lib/mcp/service', () => ({
39+
mcpService: { discoverServerTools: mockDiscoverServerTools },
40+
}))
41+
42+
import { GET } from './route'
43+
44+
describe('MCP OAuth callback route', () => {
45+
beforeEach(() => {
46+
vi.clearAllMocks()
47+
resetDbChainMock()
48+
mockCreateSsrfGuardedMcpFetch.mockReturnValue(mockGuardedFetch)
49+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
50+
mcpOauthMockFns.mockLoadOauthRowByState.mockResolvedValue({
51+
id: 'oauth-row-1',
52+
mcpServerId: 'server-1',
53+
userId: 'user-1',
54+
workspaceId: 'workspace-1',
55+
})
56+
dbChainMockFns.limit.mockResolvedValue([
57+
{
58+
id: 'server-1',
59+
url: 'https://mcp.example.com/mcp',
60+
workspaceId: 'workspace-1',
61+
},
62+
])
63+
mcpOauthMockFns.mockLoadPreregisteredClient.mockResolvedValue(undefined)
64+
mockMcpAuth.mockResolvedValue('AUTHORIZED')
65+
mockDiscoverServerTools.mockResolvedValue(undefined)
66+
})
67+
68+
it('performs the token exchange through the SSRF-guarded fetch', async () => {
69+
const request = new NextRequest(
70+
'http://localhost:3000/api/mcp/oauth/callback?state=state-1&code=auth-code-1'
71+
)
72+
73+
await GET(request)
74+
75+
expect(mockCreateSsrfGuardedMcpFetch).toHaveBeenCalledTimes(1)
76+
expect(mockMcpAuth).toHaveBeenCalledWith(
77+
expect.anything(),
78+
expect.objectContaining({
79+
serverUrl: 'https://mcp.example.com/mcp',
80+
authorizationCode: 'auth-code-1',
81+
fetchFn: mockGuardedFetch,
82+
})
83+
)
84+
})
85+
})

apps/sim/app/api/mcp/oauth/callback/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type McpOauthCallbackReason,
2020
SimMcpOauthProvider,
2121
} from '@/lib/mcp/oauth'
22+
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
2223
import { mcpService } from '@/lib/mcp/service'
2324

2425
const logger = createLogger('McpOauthCallbackAPI')
@@ -149,6 +150,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
149150
result = await mcpAuth(provider, {
150151
serverUrl: server.url,
151152
authorizationCode: code,
153+
fetchFn: createSsrfGuardedMcpFetch(),
152154
})
153155
} catch (e) {
154156
logger.error('Token exchange failed during MCP OAuth callback', e)

0 commit comments

Comments
 (0)