Skip to content

Commit 0e477d7

Browse files
authored
v0.8.59: support oauth for workflow MCPs
2 parents 5d64958 + e2cdee6 commit 0e477d7

13 files changed

Lines changed: 550 additions & 45 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/** @vitest-environment node */
2+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
3+
import { NextRequest } from 'next/server'
4+
import { afterAll, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))
7+
8+
import { GET } from '@/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route'
9+
10+
afterAll(resetEnvFlagsMock)
11+
12+
describe('workflow MCP protected-resource metadata', () => {
13+
it('names the workflow MCP server URL as a Sim API resource', async () => {
14+
setEnvFlags({ isAuthDisabled: false })
15+
const response = await GET(new NextRequest('https://sim.test/'), {
16+
params: Promise.resolve({ serverId: 'server-1' }),
17+
})
18+
expect(await response.json()).toEqual({
19+
resource: 'https://sim.test/api/mcp/serve/server-1',
20+
resource_name: 'Sim workflow MCP server',
21+
authorization_servers: ['https://sim.test/api/auth'],
22+
scopes_supported: ['api:read', 'api:write'],
23+
bearer_methods_supported: ['header'],
24+
})
25+
})
26+
27+
it('does not advertise disabled OAuth', async () => {
28+
setEnvFlags({ isAuthDisabled: true })
29+
const response = await GET(new NextRequest('https://sim.test/'), {
30+
params: Promise.resolve({ serverId: 'server-1' }),
31+
})
32+
expect(response.status).toBe(404)
33+
})
34+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { mcpServeRouteParamsSchema } from '@/lib/api/contracts/mcp'
3+
import { isAuthDisabled } from '@/lib/core/config/env-flags'
4+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { workflowMcpResourceMetadata } from '@/lib/mcp/oauth-metadata'
6+
7+
/**
8+
* RFC 9728 metadata for a workflow MCP server. Public protocol metadata, so it
9+
* describes the endpoint without looking the server up.
10+
*/
11+
export const GET = withRouteHandler(
12+
async (_request: NextRequest, context: { params: Promise<{ serverId: string }> }) => {
13+
if (isAuthDisabled) return new NextResponse(null, { status: 404 })
14+
const parsed = mcpServeRouteParamsSchema.safeParse(await context.params)
15+
if (!parsed.success) return new NextResponse(null, { status: 404 })
16+
return workflowMcpResourceMetadata(parsed.data.serverId)
17+
}
18+
)

‎apps/sim/app/api/auth/oauth2/authorize/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
135135
return invalidRequest(
136136
resource.kind === 'search'
137137
? searchScopeRequired
138-
: 'The Sim MCP server requires the api:read or api:write scope.'
138+
: 'This MCP server requires the api:read or api:write scope.'
139139
)
140140
}
141141
if (

‎apps/sim/app/api/mcp/serve/[serverId]/route.test.ts‎

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,21 @@ const {
2323
mockGenerateInternalToken,
2424
mockResolveBillingAttribution,
2525
mockSerializeBillingAttributionHeader,
26+
mockVerifyOAuthAccessToken,
27+
MockInvalidOAuthAccessTokenError,
2628
fetchMock,
2729
} = vi.hoisted(() => ({
2830
mockExecuteWorkflowService: vi.fn(),
2931
mockAssertBillingAttributionSnapshot: vi.fn(),
3032
mockGenerateInternalToken: vi.fn(),
3133
mockResolveBillingAttribution: vi.fn(),
3234
mockSerializeBillingAttributionHeader: vi.fn(),
35+
mockVerifyOAuthAccessToken: vi.fn(),
36+
MockInvalidOAuthAccessTokenError: class extends Error {
37+
constructor(readonly reason: string) {
38+
super('Invalid access token')
39+
}
40+
},
3341
fetchMock: vi.fn(),
3442
}))
3543

@@ -82,6 +90,41 @@ const PERSONAL_API_KEY_PRINCIPAL = {
8290
keyId: 'personal-key-1',
8391
} as const
8492

93+
const OAUTH_WRITE_PRINCIPAL = {
94+
kind: 'oauth_access_token',
95+
userId: 'user-1',
96+
clientId: 'client-1',
97+
tokenId: 'token-1',
98+
scopes: ['api:write', 'offline_access'],
99+
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
100+
} as const
101+
102+
const PRIVATE_SERVER = {
103+
id: 'server-1',
104+
name: 'Private Server',
105+
workspaceId: 'ws-1',
106+
isPublic: false,
107+
createdBy: 'owner-1',
108+
workspaceAllowsPersonalApiKeys: true,
109+
}
110+
111+
const SERVER_RESOURCE = 'http://localhost:3000/api/mcp/serve/server-1'
112+
const SERVER_RESOURCE_METADATA =
113+
'http://localhost:3000/.well-known/oauth-protected-resource/api/mcp/serve/server-1'
114+
115+
function toolCallRequest(headers: Record<string, string>) {
116+
return new NextRequest(SERVER_RESOURCE, {
117+
method: 'POST',
118+
headers: { Accept: 'application/json', ...headers },
119+
body: JSON.stringify({
120+
jsonrpc: '2.0',
121+
id: 1,
122+
method: 'tools/call',
123+
params: { name: 'tool_a', arguments: { q: 'test' } },
124+
}),
125+
})
126+
}
127+
85128
const WORKSPACE_API_KEY_PRINCIPAL = {
86129
kind: 'workspace_api_key',
87130
workspaceId: 'ws-1',
@@ -94,6 +137,13 @@ vi.mock('@/lib/auth/internal', () => ({
94137
generateInternalToken: mockGenerateInternalToken,
95138
}))
96139

140+
vi.mock('@/lib/auth/oauth-access-token', () => ({
141+
InvalidOAuthAccessTokenError: MockInvalidOAuthAccessTokenError,
142+
parseBearerToken: (headers: Headers) =>
143+
headers.get('authorization')?.replace(/^Bearer +/i, '') || null,
144+
verifyOAuthAccessToken: mockVerifyOAuthAccessToken,
145+
}))
146+
97147
vi.mock('@/lib/core/execution-limits', () => ({
98148
getMaxExecutionTimeout: () => 60_000,
99149
}))
@@ -154,6 +204,188 @@ describe('MCP Serve Route', () => {
154204
expect(response.status).toBe(401)
155205
})
156206

207+
describe('OAuth access tokens', () => {
208+
it('challenges an unauthenticated request with the server protected-resource metadata', async () => {
209+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
210+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
211+
success: false,
212+
error: 'Unauthorized',
213+
})
214+
215+
const response = await POST(toolCallRequest({}), {
216+
params: Promise.resolve({ serverId: 'server-1' }),
217+
})
218+
219+
expect(response.status).toBe(401)
220+
expect(response.headers.get('www-authenticate')).toBe(
221+
`Bearer resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read api:write"`
222+
)
223+
})
224+
225+
it('executes as the token user when the token is bound to this server', async () => {
226+
dbChainMockFns.limit
227+
.mockResolvedValueOnce([PRIVATE_SERVER])
228+
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
229+
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
230+
mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL)
231+
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
232+
mockExecuteWorkflowService.mockResolvedValueOnce({
233+
ok: true,
234+
executionId: 'exec-1',
235+
workflowId: 'wf-1',
236+
status: 'completed',
237+
aborted: null,
238+
output: { ok: true },
239+
error: null,
240+
hasResponseBlock: false,
241+
resolvedSecretTraceProvenance: createResolvedSecretTraceProvenance('user-1'),
242+
})
243+
244+
const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), {
245+
params: Promise.resolve({ serverId: 'server-1' }),
246+
})
247+
248+
expect(response.status).toBe(200)
249+
expect(mockVerifyOAuthAccessToken).toHaveBeenCalledWith('sim_oat_valid', {
250+
resource: SERVER_RESOURCE,
251+
})
252+
expect(hybridAuthMockFns.mockCheckHybridAuth).not.toHaveBeenCalled()
253+
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
254+
expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
255+
expect.objectContaining({
256+
userId: 'user-1',
257+
principal: OAUTH_WRITE_PRINCIPAL,
258+
useAuthenticatedUserAsActor: true,
259+
})
260+
)
261+
})
262+
263+
it('answers a token bound elsewhere with invalid_token so the client re-authorizes', async () => {
264+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
265+
mockVerifyOAuthAccessToken.mockRejectedValueOnce(
266+
new MockInvalidOAuthAccessTokenError('wrong_resource')
267+
)
268+
269+
const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_other' }), {
270+
params: Promise.resolve({ serverId: 'server-1' }),
271+
})
272+
273+
expect(response.status).toBe(401)
274+
expect(response.headers.get('www-authenticate')).toBe(
275+
`Bearer error="invalid_token", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read api:write"`
276+
)
277+
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
278+
})
279+
280+
it('asks a read-only token to step up to api:write before calling a tool', async () => {
281+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
282+
mockVerifyOAuthAccessToken.mockResolvedValueOnce({
283+
...OAUTH_WRITE_PRINCIPAL,
284+
scopes: ['api:read'],
285+
})
286+
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
287+
288+
const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_read' }), {
289+
params: Promise.resolve({ serverId: 'server-1' }),
290+
})
291+
292+
expect(response.status).toBe(403)
293+
expect(response.headers.get('www-authenticate')).toBe(
294+
`Bearer error="insufficient_scope", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:write"`
295+
)
296+
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
297+
})
298+
299+
it('refuses a token without api:read before serving tool metadata', async () => {
300+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
301+
mockVerifyOAuthAccessToken.mockResolvedValueOnce({
302+
...OAUTH_WRITE_PRINCIPAL,
303+
scopes: ['offline_access'],
304+
})
305+
306+
const response = await POST(
307+
new NextRequest(SERVER_RESOURCE, {
308+
method: 'POST',
309+
headers: { Authorization: 'Bearer sim_oat_offline' },
310+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
311+
}),
312+
{ params: Promise.resolve({ serverId: 'server-1' }) }
313+
)
314+
315+
expect(response.status).toBe(403)
316+
expect(response.headers.get('www-authenticate')).toBe(
317+
`Bearer error="insufficient_scope", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read"`
318+
)
319+
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
320+
})
321+
322+
it('lets a read-only token initialize the session', async () => {
323+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
324+
mockVerifyOAuthAccessToken.mockResolvedValueOnce({
325+
...OAUTH_WRITE_PRINCIPAL,
326+
scopes: ['api:read'],
327+
})
328+
mockGetUserEntityPermissions.mockResolvedValueOnce('read')
329+
330+
const response = await POST(
331+
new NextRequest(SERVER_RESOURCE, {
332+
method: 'POST',
333+
headers: { Authorization: 'Bearer sim_oat_read' },
334+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
335+
}),
336+
{ params: Promise.resolve({ serverId: 'server-1' }) }
337+
)
338+
339+
expect(response.status).toBe(200)
340+
})
341+
342+
it('refuses a token user who is no longer a workspace member', async () => {
343+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
344+
mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL)
345+
mockGetUserEntityPermissions.mockResolvedValueOnce(null)
346+
347+
const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), {
348+
params: Promise.resolve({ serverId: 'server-1' }),
349+
})
350+
351+
expect(response.status).toBe(403)
352+
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
353+
})
354+
355+
it('applies the workspace personal-key policy to OAuth tokens', async () => {
356+
dbChainMockFns.limit.mockResolvedValueOnce([
357+
{ ...PRIVATE_SERVER, workspaceAllowsPersonalApiKeys: false },
358+
])
359+
mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL)
360+
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
361+
362+
const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), {
363+
params: Promise.resolve({ serverId: 'server-1' }),
364+
})
365+
const body = await response.json()
366+
367+
expect(response.status).toBe(403)
368+
expect(body.error).toBe(PERSONAL_KEY_DENIED)
369+
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
370+
})
371+
372+
it('prefers an API key over a bearer token when both are sent', async () => {
373+
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
374+
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
375+
success: false,
376+
error: 'Invalid API key',
377+
})
378+
379+
const response = await POST(
380+
toolCallRequest({ Authorization: 'Bearer sim_oat_valid', 'X-API-Key': 'bad-key' }),
381+
{ params: Promise.resolve({ serverId: 'server-1' }) }
382+
)
383+
384+
expect(response.status).toBe(401)
385+
expect(mockVerifyOAuthAccessToken).not.toHaveBeenCalled()
386+
})
387+
})
388+
157389
it('returns 401 on GET for private server when auth fails', async () => {
158390
dbChainMockFns.limit.mockResolvedValueOnce([
159391
{

0 commit comments

Comments
 (0)