Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** @vitest-environment node */
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterAll, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' }))

import { GET } from '@/app/.well-known/oauth-protected-resource/api/mcp/serve/[serverId]/route'

afterAll(resetEnvFlagsMock)

describe('workflow MCP protected-resource metadata', () => {
it('names the workflow MCP server URL as a Sim API resource', async () => {
setEnvFlags({ isAuthDisabled: false })
const response = await GET(new NextRequest('https://sim.test/'), {
params: Promise.resolve({ serverId: 'server-1' }),
})
expect(await response.json()).toEqual({
resource: 'https://sim.test/api/mcp/serve/server-1',
resource_name: 'Sim workflow MCP server',
authorization_servers: ['https://sim.test/api/auth'],
scopes_supported: ['api:read', 'api:write'],
bearer_methods_supported: ['header'],
})
})

it('does not advertise disabled OAuth', async () => {
setEnvFlags({ isAuthDisabled: true })
const response = await GET(new NextRequest('https://sim.test/'), {
params: Promise.resolve({ serverId: 'server-1' }),
})
expect(response.status).toBe(404)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type NextRequest, NextResponse } from 'next/server'
import { mcpServeRouteParamsSchema } from '@/lib/api/contracts/mcp'
import { isAuthDisabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { workflowMcpResourceMetadata } from '@/lib/mcp/oauth-metadata'

/**
* RFC 9728 metadata for a workflow MCP server. Public protocol metadata, so it
* describes the endpoint without looking the server up.
*/
export const GET = withRouteHandler(
async (_request: NextRequest, context: { params: Promise<{ serverId: string }> }) => {
if (isAuthDisabled) return new NextResponse(null, { status: 404 })
const parsed = mcpServeRouteParamsSchema.safeParse(await context.params)
if (!parsed.success) return new NextResponse(null, { status: 404 })
return workflowMcpResourceMetadata(parsed.data.serverId)
}
)
2 changes: 1 addition & 1 deletion apps/sim/app/api/auth/oauth2/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return invalidRequest(
resource.kind === 'search'
? searchScopeRequired
: 'The Sim MCP server requires the api:read or api:write scope.'
: 'This MCP server requires the api:read or api:write scope.'
)
}
if (
Expand Down
232 changes: 232 additions & 0 deletions apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,21 @@ const {
mockGenerateInternalToken,
mockResolveBillingAttribution,
mockSerializeBillingAttributionHeader,
mockVerifyOAuthAccessToken,
MockInvalidOAuthAccessTokenError,
fetchMock,
} = vi.hoisted(() => ({
mockExecuteWorkflowService: vi.fn(),
mockAssertBillingAttributionSnapshot: vi.fn(),
mockGenerateInternalToken: vi.fn(),
mockResolveBillingAttribution: vi.fn(),
mockSerializeBillingAttributionHeader: vi.fn(),
mockVerifyOAuthAccessToken: vi.fn(),
MockInvalidOAuthAccessTokenError: class extends Error {
constructor(readonly reason: string) {
super('Invalid access token')
}
},
fetchMock: vi.fn(),
}))

Expand Down Expand Up @@ -82,6 +90,41 @@ const PERSONAL_API_KEY_PRINCIPAL = {
keyId: 'personal-key-1',
} as const

const OAUTH_WRITE_PRINCIPAL = {
kind: 'oauth_access_token',
userId: 'user-1',
clientId: 'client-1',
tokenId: 'token-1',
scopes: ['api:write', 'offline_access'],
expiresAt: new Date('2099-01-01T00:00:00.000Z'),
} as const

const PRIVATE_SERVER = {
id: 'server-1',
name: 'Private Server',
workspaceId: 'ws-1',
isPublic: false,
createdBy: 'owner-1',
workspaceAllowsPersonalApiKeys: true,
}

const SERVER_RESOURCE = 'http://localhost:3000/api/mcp/serve/server-1'
const SERVER_RESOURCE_METADATA =
'http://localhost:3000/.well-known/oauth-protected-resource/api/mcp/serve/server-1'

function toolCallRequest(headers: Record<string, string>) {
return new NextRequest(SERVER_RESOURCE, {
method: 'POST',
headers: { Accept: 'application/json', ...headers },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'tool_a', arguments: { q: 'test' } },
}),
})
}

const WORKSPACE_API_KEY_PRINCIPAL = {
kind: 'workspace_api_key',
workspaceId: 'ws-1',
Expand All @@ -94,6 +137,13 @@ vi.mock('@/lib/auth/internal', () => ({
generateInternalToken: mockGenerateInternalToken,
}))

vi.mock('@/lib/auth/oauth-access-token', () => ({
InvalidOAuthAccessTokenError: MockInvalidOAuthAccessTokenError,
parseBearerToken: (headers: Headers) =>
headers.get('authorization')?.replace(/^Bearer +/i, '') || null,
verifyOAuthAccessToken: mockVerifyOAuthAccessToken,
}))

vi.mock('@/lib/core/execution-limits', () => ({
getMaxExecutionTimeout: () => 60_000,
}))
Expand Down Expand Up @@ -154,6 +204,188 @@ describe('MCP Serve Route', () => {
expect(response.status).toBe(401)
})

describe('OAuth access tokens', () => {
it('challenges an unauthenticated request with the server protected-resource metadata', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})

const response = await POST(toolCallRequest({}), {
params: Promise.resolve({ serverId: 'server-1' }),
})

expect(response.status).toBe(401)
expect(response.headers.get('www-authenticate')).toBe(
`Bearer resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read api:write"`
)
})

it('executes as the token user when the token is bound to this server', async () => {
dbChainMockFns.limit
.mockResolvedValueOnce([PRIVATE_SERVER])
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
.mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }])
mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL)
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
mockExecuteWorkflowService.mockResolvedValueOnce({
ok: true,
executionId: 'exec-1',
workflowId: 'wf-1',
status: 'completed',
aborted: null,
output: { ok: true },
error: null,
hasResponseBlock: false,
resolvedSecretTraceProvenance: createResolvedSecretTraceProvenance('user-1'),
})

const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), {
params: Promise.resolve({ serverId: 'server-1' }),
})

expect(response.status).toBe(200)
expect(mockVerifyOAuthAccessToken).toHaveBeenCalledWith('sim_oat_valid', {
resource: SERVER_RESOURCE,
})
expect(hybridAuthMockFns.mockCheckHybridAuth).not.toHaveBeenCalled()
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'ws-1')
expect(mockExecuteWorkflowService).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-1',
principal: OAUTH_WRITE_PRINCIPAL,
useAuthenticatedUserAsActor: true,
})
)
})

it('answers a token bound elsewhere with invalid_token so the client re-authorizes', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
mockVerifyOAuthAccessToken.mockRejectedValueOnce(
new MockInvalidOAuthAccessTokenError('wrong_resource')
)

const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_other' }), {
params: Promise.resolve({ serverId: 'server-1' }),
})

expect(response.status).toBe(401)
expect(response.headers.get('www-authenticate')).toBe(
`Bearer error="invalid_token", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read api:write"`
)
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
})

it('asks a read-only token to step up to api:write before calling a tool', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
mockVerifyOAuthAccessToken.mockResolvedValueOnce({
...OAUTH_WRITE_PRINCIPAL,
scopes: ['api:read'],
})
mockGetUserEntityPermissions.mockResolvedValueOnce('write')

const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_read' }), {
params: Promise.resolve({ serverId: 'server-1' }),
})

expect(response.status).toBe(403)
expect(response.headers.get('www-authenticate')).toBe(
`Bearer error="insufficient_scope", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:write"`
)
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
})

it('refuses a token without api:read before serving tool metadata', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
mockVerifyOAuthAccessToken.mockResolvedValueOnce({
...OAUTH_WRITE_PRINCIPAL,
scopes: ['offline_access'],
})

const response = await POST(
new NextRequest(SERVER_RESOURCE, {
method: 'POST',
headers: { Authorization: 'Bearer sim_oat_offline' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
}),
{ params: Promise.resolve({ serverId: 'server-1' }) }
)

expect(response.status).toBe(403)
expect(response.headers.get('www-authenticate')).toBe(
`Bearer error="insufficient_scope", resource_metadata="${SERVER_RESOURCE_METADATA}", scope="api:read"`
)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})

it('lets a read-only token initialize the session', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
mockVerifyOAuthAccessToken.mockResolvedValueOnce({
...OAUTH_WRITE_PRINCIPAL,
scopes: ['api:read'],
})
mockGetUserEntityPermissions.mockResolvedValueOnce('read')

const response = await POST(
new NextRequest(SERVER_RESOURCE, {
method: 'POST',
headers: { Authorization: 'Bearer sim_oat_read' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
}),
{ params: Promise.resolve({ serverId: 'server-1' }) }
)

expect(response.status).toBe(200)
})

it('refuses a token user who is no longer a workspace member', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL)
mockGetUserEntityPermissions.mockResolvedValueOnce(null)

const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), {
params: Promise.resolve({ serverId: 'server-1' }),
})

expect(response.status).toBe(403)
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
})

it('applies the workspace personal-key policy to OAuth tokens', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{ ...PRIVATE_SERVER, workspaceAllowsPersonalApiKeys: false },
])
mockVerifyOAuthAccessToken.mockResolvedValueOnce(OAUTH_WRITE_PRINCIPAL)
mockGetUserEntityPermissions.mockResolvedValueOnce('write')

const response = await POST(toolCallRequest({ Authorization: 'Bearer sim_oat_valid' }), {
params: Promise.resolve({ serverId: 'server-1' }),
})
const body = await response.json()

expect(response.status).toBe(403)
expect(body.error).toBe(PERSONAL_KEY_DENIED)
expect(mockExecuteWorkflowService).not.toHaveBeenCalled()
})

it('prefers an API key over a bearer token when both are sent', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([PRIVATE_SERVER])
hybridAuthMockFns.mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Invalid API key',
})

const response = await POST(
toolCallRequest({ Authorization: 'Bearer sim_oat_valid', 'X-API-Key': 'bad-key' }),
{ params: Promise.resolve({ serverId: 'server-1' }) }
)

expect(response.status).toBe(401)
expect(mockVerifyOAuthAccessToken).not.toHaveBeenCalled()
})
})

it('returns 401 on GET for private server when auth fails', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([
{
Expand Down
Loading
Loading