diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index 85c1490c4..bad687366 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -1,4 +1,10 @@ import type { Context, Next } from 'hono'; +import { + getLegacyRoomoteMcpResourceUrl, + getRoomoteMcpProtectedResourceMetadataUrl, + getRoomoteMcpResourceUrl, +} from '@roomote/auth'; +import { Env } from '@roomote/env'; import type { Variables } from '../types'; @@ -75,6 +81,38 @@ vi.mock('../middleware', async (importOriginal) => { } as Variables['authContext']); } + if (authHeader === 'Bearer test-mcp-token') { + c.set('authContext', { + tokenType: 'mcp', + userId: 'user-123', + resource: getRoomoteMcpResourceUrl( + Env.R_PUBLIC_URL ?? Env.R_APP_URL, + ), + scopes: ['mcp:roomote'], + version: 1, + } as Variables['authContext']); + } + + if (authHeader === 'Bearer test-wrong-mcp-token') { + c.set('authContext', { + tokenType: 'mcp', + userId: 'user-123', + resource: 'https://wrong.example/mcp', + scopes: ['mcp:roomote'], + version: 1, + } as Variables['authContext']); + } + + if (authHeader === 'Bearer test-legacy-mcp-token') { + c.set('authContext', { + tokenType: 'mcp', + userId: 'user-123', + resource: getLegacyRoomoteMcpResourceUrl(Env.TRPC_URL), + scopes: ['mcp:roomote'], + version: 1, + } as Variables['authContext']); + } + await next(); }, }; @@ -113,6 +151,22 @@ describe('route policy enforcement', () => { }); }); + describe('Roomote MCP OAuth discovery', () => { + it('publishes protected-resource metadata without authentication', async () => { + const response = await createApiApp().request( + 'http://localhost/.well-known/oauth-protected-resource/mcp', + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), + authorization_servers: [expect.any(String)], + bearer_methods_supported: ['header'], + scopes_supported: ['mcp:roomote'], + }); + }); + }); + describe('public routes', () => { it('serves health liveness without credentials', async () => { const response = await createApiApp().request( @@ -166,16 +220,127 @@ describe('route policy enforcement', () => { await expect(mcpResponse.json()).resolves.toEqual(jsonRpcUnauthorized); const mcpRoutingResponse = await createApiApp().request( - 'http://localhost/api/mcp-routing/roomote', + 'http://localhost/mcp', { method: 'POST', body: '{}' }, ); expect(mcpRoutingResponse.status).toBe(401); + expect(mcpRoutingResponse.headers.get('www-authenticate')).toBe( + `Bearer resource_metadata="${getRoomoteMcpProtectedResourceMetadataUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL)}"`, + ); await expect(mcpRoutingResponse.json()).resolves.toEqual( jsonRpcUnauthorized, ); }); + it('rejects MCP OAuth tokens outside the Roomote MCP resource', async () => { + const response = await createApiApp().request( + 'http://localhost/api/task-runs/123/logs', + { headers: { authorization: 'Bearer test-mcp-token' } }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: 'mcp_token_not_allowed', + }); + }); + + it('rejects an MCP token whose audience does not match the configured resource', async () => { + const response = await createApiApp().request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer test-wrong-mcp-token' }, + body: '{}', + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { message: expect.stringContaining('requires a user-scoped') }, + }); + }); + + it('rejects a legacy-audience token at the broad public MCP endpoint', async () => { + const response = await createApiApp().request('http://localhost/mcp', { + method: 'POST', + headers: { authorization: 'Bearer test-legacy-mcp-token' }, + body: '{}', + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { message: expect.stringContaining('requires a user-scoped') }, + }); + }); + + it('exposes member task tools only on the public /mcp endpoint', async () => { + const request = { + method: 'POST', + headers: { + authorization: 'Bearer test-mcp-token', + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: {}, + }), + }; + const publicResponse = await createApiApp().request( + 'http://localhost/mcp', + request, + ); + const publicBody = (await publicResponse.json()) as { + result?: { tools?: Array<{ name: string }> }; + }; + expect(publicResponse.status).toBe(200); + expect(publicBody.result?.tools?.map((tool) => tool.name)).toContain( + 'manage_tasks', + ); + + const callResponse = await createApiApp().request( + 'http://localhost/mcp', + { + ...request, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'manage_tasks', + arguments: { action: 'list_environments' }, + }, + }), + }, + ); + const callBody = (await callResponse.json()) as { + result?: { isError?: boolean; structuredContent?: unknown }; + }; + expect(callResponse.status).toBe(200); + expect(callBody.result?.isError).not.toBe(true); + expect(callBody.result?.structuredContent).toMatchObject({ + environments: expect.any(Array), + }); + + const legacyResponse = await createApiApp().request( + 'http://localhost/api/mcp-routing/roomote', + { + ...request, + headers: { + ...request.headers, + authorization: 'Bearer test-user-token', + }, + }, + ); + const legacyBody = (await legacyResponse.json()) as { + result?: { tools?: Array<{ name: string }> }; + }; + expect(legacyResponse.status).toBe(200); + expect(legacyBody.result?.tools?.map((tool) => tool.name)).not.toContain( + 'manage_tasks', + ); + }); + it('lets run-token requests through to handler-level run scoping', async () => { // The token is scoped to run 999, so the handler (not the policy // layer) rejects access to run 123. Reaching that handler check @@ -397,6 +562,14 @@ describe('route policy enforcement', () => { version: 1, } as Variables['authContext']; + const mcpToken = { + tokenType: 'mcp', + userId: 'user-123', + resource: 'https://api.example.com/mcp', + scopes: ['mcp:roomote'], + version: 1, + } as Variables['authContext']; + it('user policy admits only user tokens', () => { expect(evaluateRoutePolicy('user', userToken)).toBeUndefined(); expect(evaluateRoutePolicy('user', runToken)).toEqual({ @@ -424,12 +597,26 @@ describe('route policy enforcement', () => { it('authenticated policy admits both token types', () => { expect(evaluateRoutePolicy('authenticated', userToken)).toBeUndefined(); expect(evaluateRoutePolicy('authenticated', runToken)).toBeUndefined(); + expect(evaluateRoutePolicy('authenticated', mcpToken)).toEqual({ + status: 403, + body: { error: 'mcp_token_not_allowed' }, + }); expect(evaluateRoutePolicy('authenticated', undefined)).toEqual({ status: 401, body: { error: 'authentication_required' }, }); }); + it('roomote-mcp policy admits internal and scoped MCP tokens', () => { + expect(evaluateRoutePolicy('roomote-mcp', userToken)).toBeUndefined(); + expect(evaluateRoutePolicy('roomote-mcp', runToken)).toBeUndefined(); + expect(evaluateRoutePolicy('roomote-mcp', mcpToken)).toBeUndefined(); + expect(evaluateRoutePolicy('roomote-mcp', undefined)).toEqual({ + status: 401, + body: { error: 'authentication_required' }, + }); + }); + it('public and webhook policies require no credentials', () => { expect(evaluateRoutePolicy('public', undefined)).toBeUndefined(); expect(evaluateRoutePolicy('webhook', undefined)).toBeUndefined(); diff --git a/apps/api/src/handlers/index.ts b/apps/api/src/handlers/index.ts index 31a99bd6f..93f8fb5a6 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -22,6 +22,8 @@ export { trpc } from './trpc'; // mcp export { mcp } from './mcp'; export { mcpRouting } from './mcp/routing'; +export { mcpOAuthMetadata } from './mcp-oauth'; +export { publicRoomoteMcp } from './mcp/roomote'; // inference gateway export { inference } from './inference'; diff --git a/apps/api/src/handlers/mcp-oauth.ts b/apps/api/src/handlers/mcp-oauth.ts new file mode 100644 index 000000000..6dc651277 --- /dev/null +++ b/apps/api/src/handlers/mcp-oauth.ts @@ -0,0 +1,38 @@ +import { Hono, type Context } from 'hono'; + +import { + getRoomoteMcpResourceUrl, + ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, + ROOMOTE_MCP_SCOPE, +} from '@roomote/auth'; +import { Env } from '@roomote/env'; + +import type { Variables } from '../types'; + +const LEGACY_ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = + '/.well-known/oauth-protected-resource/api/mcp-routing/roomote'; + +export const mcpOAuthMetadata = new Hono<{ Variables: Variables }>(); + +const protectedResourceMetadataHandler = ( + c: Context<{ Variables: Variables }>, +) => { + const authorizationServer = Env.R_PUBLIC_URL ?? Env.R_APP_URL; + + c.header('Cache-Control', 'public, max-age=3600'); + return c.json({ + resource: getRoomoteMcpResourceUrl(authorizationServer), + authorization_servers: [new URL(authorizationServer).origin], + bearer_methods_supported: ['header'], + scopes_supported: [ROOMOTE_MCP_SCOPE], + }); +}; + +mcpOAuthMetadata.get( + ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, + protectedResourceMetadataHandler, +); +mcpOAuthMetadata.get( + LEGACY_ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, + protectedResourceMetadataHandler, +); diff --git a/apps/api/src/handlers/mcp/roomote-member-tools.ts b/apps/api/src/handlers/mcp/roomote-member-tools.ts new file mode 100644 index 000000000..e90335c39 --- /dev/null +++ b/apps/api/src/handlers/mcp/roomote-member-tools.ts @@ -0,0 +1,229 @@ +import { Hono } from 'hono'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { ALL_REPOSITORIES, PRODUCT_NAME } from '@roomote/types'; + +import type { Variables } from '../../types'; +import { environmentsRouter } from '../environments'; +import { tasksRouter } from '../tasks'; +import type { McpAuth } from './middleware'; +import { toMcpToolResult } from './proxy-utils'; + +type MemberApiResult = { + ok: boolean; + status: number; + payload: Record; +}; + +function toolError(payload: Record) { + return { ...toMcpToolResult(payload), isError: true as const }; +} + +async function invokeMemberApi( + auth: McpAuth, + path: string, + init?: RequestInit, +): Promise { + const app = new Hono<{ + Variables: Variables & { mcpAuth: McpAuth }; + }>(); + app.use('*', async (c, next) => { + c.set('authContext', auth.authContext); + c.set('mcpAuth', auth); + await next(); + }); + app.route('/tasks', tasksRouter); + app.route('/environments', environmentsRouter); + + const response = await app.request(`http://roomote.internal${path}`, init); + const rawPayload: unknown = await response.json(); + const payload = + rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload) + ? (rawPayload as Record) + : { result: rawPayload }; + + return { ok: response.ok, status: response.status, payload }; +} + +function resultFromApi(result: MemberApiResult) { + return result.ok + ? toMcpToolResult(result.payload) + : toolError({ status: result.status, ...result.payload }); +} + +const manageTasksInputSchema = { + action: z.enum([ + 'search', + 'get_summary', + 'get_compute_logs', + 'get_messages', + 'launch', + 'cancel', + 'send_message', + 'list_environments', + ]), + taskId: z.string().optional(), + message: z.string().optional(), + query: z.string().optional(), + status: z.enum(['active', 'completed', 'all']).optional(), + pullRequest: z.string().optional(), + limit: z.number().int().min(1).max(1000).optional(), + cursor: z.string().optional(), + prompt: z.string().optional(), + environmentId: z.string().optional(), + branch: z.string().optional(), + notifyOnSettle: z.boolean().optional(), +} satisfies Record; + +export function registerRoomoteMemberTools( + server: McpServer, + auth: McpAuth, +): void { + server.registerTool( + 'manage_tasks', + { + title: 'Manage Tasks', + description: + `Manage ${PRODUCT_NAME} tasks as the signed-in member. ` + + 'Use list_environments immediately before launch, search for task history, inspect summaries/messages/compute logs, launch tasks, cancel active tasks, or send follow-up messages.', + inputSchema: manageTasksInputSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (params) => { + switch (params.action) { + case 'search': { + const query = new URLSearchParams(); + if (params.query) query.set('query', params.query); + if (params.status) query.set('status', params.status); + if (params.pullRequest) query.set('pullRequest', params.pullRequest); + if (params.limit) + query.set('limit', String(Math.min(params.limit, 100))); + if (params.cursor) query.set('cursor', params.cursor); + const suffix = query.size > 0 ? `?${query.toString()}` : ''; + return resultFromApi(await invokeMemberApi(auth, `/tasks${suffix}`)); + } + case 'get_summary': + case 'get_compute_logs': + case 'get_messages': { + if (!params.taskId?.trim()) { + return toolError({ + error: `taskId is required for ${params.action}`, + }); + } + const actionPath = { + get_summary: 'summary', + get_compute_logs: 'compute_logs', + get_messages: 'messages', + }[params.action]; + const query = new URLSearchParams(); + if (params.action === 'get_messages') { + query.set('order', 'desc'); + if (params.limit) query.set('limit', String(params.limit)); + } + const suffix = query.size > 0 ? `?${query.toString()}` : ''; + return resultFromApi( + await invokeMemberApi( + auth, + `/tasks/${encodeURIComponent(params.taskId)}/${actionPath}${suffix}`, + ), + ); + } + case 'launch': { + if (!params.prompt?.trim()) { + return toolError({ error: 'prompt is required for launch' }); + } + if (!params.environmentId?.trim()) { + return toolError({ + error: + 'environmentId is required for launch; call list_environments first', + }); + } + return resultFromApi( + await invokeMemberApi(auth, '/tasks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: params.prompt, + repo: ALL_REPOSITORIES, + branch: params.branch, + environmentId: + params.environmentId === ALL_REPOSITORIES + ? undefined + : params.environmentId, + type: 'standard', + notifyOnSettle: params.notifyOnSettle, + }), + }), + ); + } + case 'cancel': { + if (!params.taskId?.trim()) { + return toolError({ error: 'taskId is required for cancel' }); + } + return resultFromApi( + await invokeMemberApi( + auth, + `/tasks/${encodeURIComponent(params.taskId)}/cancel`, + { method: 'POST' }, + ), + ); + } + case 'send_message': { + if (!params.taskId?.trim()) { + return toolError({ error: 'taskId is required for send_message' }); + } + if (!params.message?.trim()) { + return toolError({ error: 'message is required for send_message' }); + } + return resultFromApi( + await invokeMemberApi( + auth, + `/tasks/${encodeURIComponent(params.taskId)}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: params.message }), + }, + ), + ); + } + case 'list_environments': { + const result = await invokeMemberApi(auth, '/environments'); + if (!result.ok) return resultFromApi(result); + const environments = Array.isArray(result.payload.environments) + ? result.payload.environments + : []; + return toMcpToolResult({ + instructions: + 'Call launch with one of these environmentId values. Do not invent an environmentId.', + environments: [ + { + environmentId: ALL_REPOSITORIES, + name: 'All repositories', + description: 'Run the task against all repositories', + }, + ...environments.map((environment) => { + const value = environment as { + id?: unknown; + name?: unknown; + description?: unknown; + }; + return { + environmentId: value.id, + name: value.name, + description: value.description, + }; + }), + ], + }); + } + } + }, + ); +} diff --git a/apps/api/src/handlers/mcp/roomote.ts b/apps/api/src/handlers/mcp/roomote.ts index a779d69a2..38f972288 100644 --- a/apps/api/src/handlers/mcp/roomote.ts +++ b/apps/api/src/handlers/mcp/roomote.ts @@ -24,6 +24,11 @@ import { PRODUCT_NAME, } from '@roomote/types'; import { Env, getDefaultDocsUrl } from '@roomote/env'; +import { + getLegacyRoomoteMcpResourceUrl, + getRoomoteMcpResourceUrl, + ROOMOTE_MCP_SCOPE, +} from '@roomote/auth'; import { z } from 'zod'; import type { Variables } from '../../types'; @@ -42,6 +47,8 @@ import { type CommunicationLookupTaskRun, } from './communication-message-lookup'; import { requireCommunicationLookupTaskRun } from './communication-lookup-run-context'; +import type { McpAuth } from './middleware'; +import { registerRoomoteMemberTools } from './roomote-member-tools'; const ROOMOTE_MCP_SERVER_INFO = { name: 'roomote-router-mcp', @@ -84,6 +91,7 @@ function getConfigRepositories(configValue: unknown): Array<{ async function resolveRoomoteMcpAuth( authContext: Variables['authContext'], + options: { allowLegacyAudience: boolean }, ): Promise { if (!authContext) { throw new McpProxyError( @@ -109,6 +117,22 @@ async function resolveRoomoteMcpAuth( }; } + if ( + authContext.tokenType === 'mcp' && + [ + getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), + ...(options.allowLegacyAudience + ? [getLegacyRoomoteMcpResourceUrl(Env.TRPC_URL)] + : []), + ].includes(authContext.resource) && + authContext.scopes.includes(ROOMOTE_MCP_SCOPE) + ) { + return { + userId: authContext.userId, + tokenType: 'auth', + }; + } + throw new McpProxyError( 403, `${PRODUCT_NAME} MCP requires a user-scoped auth token or task run token`, @@ -346,11 +370,16 @@ function createRoomoteTransport() { function createRoomoteMcpServer( auth: McpAuthContext, actingUserId: string | null, + memberAuth?: McpAuth, ) { const server = new McpServer(ROOMOTE_MCP_SERVER_INFO, { instructions: `Use get_about_me for Roomote platform, integration, and getting-started context. Use ${CHAT_MESSAGE_CONTEXT_TOOL.name} for surrounding context from the task communication channel or a referenced Slack/Discord message. Use ${CHAT_CHANNEL_MESSAGES_TOOL.name} for readable history from the task communication channel or an explicitly linked channel.`, }); + if (memberAuth) { + registerRoomoteMemberTools(server, memberAuth); + } + server.registerTool( 'get_about_me', { @@ -477,49 +506,80 @@ function createRoomoteMcpServer( return server; } -export const roomoteMcp = new Hono<{ Variables: Variables }>(); - -roomoteMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { - const transport = createRoomoteTransport(); - - try { - const auth = await resolveRoomoteMcpAuth(c.get('authContext')); - // Null means the job runs as the deployment service principal; the - // Roomote MCP tools are informational and operate on deployment-scoped - // data, so they support that case. - const actingUserId = await resolveActingUserIdOrNull(auth); - const server = createRoomoteMcpServer(auth, actingUserId); +function createRoomoteMcpRouter(options: { + memberTools: boolean; + allowLegacyAudience: boolean; +}) { + const router = new Hono<{ Variables: Variables }>(); + + router.on(['POST', 'GET', 'DELETE'], '/', async (c) => { + const transport = createRoomoteTransport(); + + try { + const rawAuth = c.get('authContext'); + const auth = await resolveRoomoteMcpAuth(rawAuth, options); + // Null means the job runs as the deployment service principal; the + // context tools are informational and support that case. Member tools + // are only mounted on the public endpoint and retain the resolved user. + const actingUserId = await resolveActingUserIdOrNull(auth); + const memberAuth = + options.memberTools && rawAuth + ? { + userId: actingUserId ?? undefined, + authContext: + rawAuth.tokenType === 'mcp' + ? { + userId: rawAuth.userId, + tokenType: 'auth' as const, + version: rawAuth.version, + } + : rawAuth, + } + : undefined; + const server = createRoomoteMcpServer(auth, actingUserId, memberAuth); + + await server.connect(transport); + return await transport.handleRequest(c.req.raw); + } catch (error) { + if (error instanceof McpProxyError) { + return Response.json( + { + jsonrpc: '2.0', + id: null, + error: { + code: -32000, + message: error.message, + }, + }, + { status: error.httpStatus }, + ); + } - await server.connect(transport); - return await transport.handleRequest(c.req.raw); - } catch (error) { - if (error instanceof McpProxyError) { return Response.json( { jsonrpc: '2.0', id: null, error: { - code: -32000, - message: error.message, + code: -32603, + message: + error instanceof Error + ? error.message + : 'Unknown Roomote MCP error', }, }, - { status: error.httpStatus }, + { status: 500 }, ); } + }); - return Response.json( - { - jsonrpc: '2.0', - id: null, - error: { - code: -32603, - message: - error instanceof Error - ? error.message - : 'Unknown Roomote MCP error', - }, - }, - { status: 500 }, - ); - } + return router; +} + +export const roomoteMcp = createRoomoteMcpRouter({ + memberTools: false, + allowLegacyAudience: true, +}); +export const publicRoomoteMcp = createRoomoteMcpRouter({ + memberTools: true, + allowLegacyAudience: false, }); diff --git a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts index 6ae22455d..5dc172906 100644 --- a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts +++ b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts @@ -2,15 +2,21 @@ import { Hono } from 'hono'; import type { Variables } from '../../types'; -const { mockValidateRunToken, mockValidateAuthToken, mockFindDeployment } = - vi.hoisted(() => ({ - mockValidateRunToken: vi.fn(), - mockValidateAuthToken: vi.fn(), - mockFindDeployment: vi.fn(), - })); +const { + mockValidateRunToken, + mockValidateMcpAccessToken, + mockValidateAuthToken, + mockFindDeployment, +} = vi.hoisted(() => ({ + mockValidateRunToken: vi.fn(), + mockValidateMcpAccessToken: vi.fn(), + mockValidateAuthToken: vi.fn(), + mockFindDeployment: vi.fn(), +})); vi.mock('@roomote/auth', () => ({ validateRunToken: mockValidateRunToken, + validateMcpAccessToken: mockValidateMcpAccessToken, validateAuthToken: mockValidateAuthToken, })); @@ -69,6 +75,7 @@ describe('tokenAuthMiddleware token extraction', () => { return RUN_TOKEN_CONTEXT; }); mockValidateAuthToken.mockRejectedValue(new Error('invalid token')); + mockValidateMcpAccessToken.mockRejectedValue(new Error('invalid token')); }); it('accepts a bearer token on any path', async () => { @@ -79,6 +86,24 @@ describe('tokenAuthMiddleware token extraction', () => { expect(authContext).toEqual(RUN_TOKEN_CONTEXT); }); + it('attaches a browser-issued MCP token for route-level authorization', async () => { + const mcpContext = { + tokenType: 'mcp', + userId: 'user-1', + resource: 'https://api.example.com/mcp', + scopes: ['mcp:roomote'], + version: 1, + }; + mockValidateMcpAccessToken.mockResolvedValue(mcpContext); + + const authContext = await requestAuthContext('/mcp', { + authorization: 'Bearer valid-mcp-token', + }); + + expect(authContext).toEqual(mcpContext); + expect(mockValidateAuthToken).not.toHaveBeenCalled(); + }); + it('accepts the run token from x-api-key on the inference gateway', async () => { const authContext = await requestAuthContext( '/api/inference/anthropic/v1/messages', diff --git a/apps/api/src/middleware/routePolicyMiddleware.ts b/apps/api/src/middleware/routePolicyMiddleware.ts index ff74d9993..72391b267 100644 --- a/apps/api/src/middleware/routePolicyMiddleware.ts +++ b/apps/api/src/middleware/routePolicyMiddleware.ts @@ -3,8 +3,10 @@ import { createHash } from 'node:crypto'; import type { Context } from 'hono'; import { createMiddleware } from 'hono/factory'; -import type { RunTokenContext } from '@roomote/types'; +import type { McpAccessTokenContext, RunTokenContext } from '@roomote/types'; import { getRedis } from '@roomote/redis'; +import { getRoomoteMcpProtectedResourceMetadataUrl } from '@roomote/auth'; +import { Env } from '@roomote/env'; import type { Variables } from '../types'; import { @@ -40,6 +42,12 @@ function isRunTokenContext( return Boolean(auth && 'runId' in auth); } +function isMcpTokenContext( + auth: Variables['authContext'], +): auth is McpAccessTokenContext { + return auth?.tokenType === 'mcp'; +} + /** * Pure policy evaluation: given a route's declared policy class and the * request's validated auth context, decide whether the request may proceed. @@ -61,6 +69,14 @@ export function evaluateRoutePolicy( // in `server.ts` (outside development). return undefined; case 'authenticated': + if (!authContext) { + return { status: 401, body: { error: 'authentication_required' } }; + } + if (isMcpTokenContext(authContext)) { + return { status: 403, body: { error: 'mcp_token_not_allowed' } }; + } + return undefined; + case 'roomote-mcp': if (!authContext) { return { status: 401, body: { error: 'authentication_required' } }; } @@ -69,7 +85,7 @@ export function evaluateRoutePolicy( if (!authContext) { return { status: 401, body: { error: 'authentication_required' } }; } - if (isRunTokenContext(authContext)) { + if (authContext.tokenType !== 'auth') { return { status: 403, body: { error: 'user_token_required' } }; } return undefined; @@ -89,6 +105,16 @@ function rejectionResponse( rule: RoutePolicyRule, rejection: RoutePolicyRejection, ): Response { + if ( + (rule.name === 'roomote-mcp' || rule.name === 'roomote-public-mcp') && + rejection.status === 401 + ) { + c.header( + 'WWW-Authenticate', + `Bearer resource_metadata="${getRoomoteMcpProtectedResourceMetadataUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL)}"`, + ); + } + if (rule.errorFormat === 'json-rpc') { // Match the JSON-RPC error envelope the MCP handlers emit themselves // (see `handlers/mcp/proxy-utils.ts`) so Streamable HTTP clients that diff --git a/apps/api/src/middleware/tokenAuthMiddleware.ts b/apps/api/src/middleware/tokenAuthMiddleware.ts index 1bdac8e84..0c50f83b6 100644 --- a/apps/api/src/middleware/tokenAuthMiddleware.ts +++ b/apps/api/src/middleware/tokenAuthMiddleware.ts @@ -1,7 +1,11 @@ import type { Context, Next } from 'hono'; import { createMiddleware } from 'hono/factory'; -import { validateRunToken, validateAuthToken } from '@roomote/auth'; +import { + validateAuthToken, + validateMcpAccessToken, + validateRunToken, +} from '@roomote/auth'; import { db, deploymentSettings, eq, users } from '@roomote/db/server'; import { isRoomoteDeploymentDisabled } from '@roomote/types'; @@ -71,23 +75,43 @@ export const tokenAuthMiddleware = () => return; } + let userScopedAuth: + | Awaited> + | Awaited> + | undefined; + try { - const authContext = await validateAuthToken(token); - if (await deploymentAllowsTokenAuth()) { + userScopedAuth = await validateMcpAccessToken(token); + } catch { + // Not an MCP OAuth token, try the internal user auth token below. + } + + try { + userScopedAuth ??= await validateAuthToken(token); + } catch (error) { + if (!userScopedAuth) { + console.error( + `Failed to validate token: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + if (userScopedAuth && (await deploymentAllowsTokenAuth())) { + try { const user = await db.query.users.findFirst({ - where: eq(users.id, authContext.userId), + where: eq(users.id, userScopedAuth.userId), columns: { id: true, deletedAt: true }, }); // Removed users keep no standing access: their API tokens die with them. if (user && user.deletedAt == null) { - c.set('authContext', authContext); + c.set('authContext', userScopedAuth); } + } catch (error) { + console.error( + `Failed to resolve token user: ${error instanceof Error ? error.message : String(error)}`, + ); } - } catch (error) { - console.error( - `Failed to validate token: ${error instanceof Error ? error.message : String(error)}`, - ); } } diff --git a/apps/api/src/route-policies.ts b/apps/api/src/route-policies.ts index aa063f590..dd969651a 100644 --- a/apps/api/src/route-policies.ts +++ b/apps/api/src/route-policies.ts @@ -32,6 +32,7 @@ export type RoutePolicyClass = | 'user' | 'task-token' | 'authenticated' + | 'roomote-mcp' | 'admin'; /** @@ -118,6 +119,22 @@ export const ROUTE_POLICY_RULES: readonly RoutePolicyRule[] = [ match: { type: 'prefix', path: '/health/controller' }, policy: 'public', }, + { + name: 'roomote-mcp-oauth-protected-resource-metadata', + match: { + type: 'exact', + path: '/.well-known/oauth-protected-resource/api/mcp-routing/roomote', + }, + policy: 'public', + }, + { + name: 'roomote-mcp-oauth-protected-resource-metadata-canonical', + match: { + type: 'exact', + path: '/.well-known/oauth-protected-resource/mcp', + }, + policy: 'public', + }, // Sandbox OIDC discovery documents consumed by external verifiers. { @@ -238,6 +255,18 @@ export const ROUTE_POLICY_RULES: readonly RoutePolicyRule[] = [ // Router-facing MCP endpoints: accept user auth tokens (LLM router // gathering context before a run exists) and task run tokens. + { + name: 'roomote-public-mcp', + match: { type: 'exact', path: '/mcp' }, + policy: 'roomote-mcp', + errorFormat: 'json-rpc', + }, + { + name: 'roomote-mcp', + match: { type: 'exact', path: '/api/mcp-routing/roomote' }, + policy: 'roomote-mcp', + errorFormat: 'json-rpc', + }, { name: 'mcp-routing', match: { type: 'prefix', path: '/api/mcp-routing' }, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b146268dc..f17267fc8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -49,6 +49,8 @@ import { inference, mcp, mcpRouting, + mcpOAuthMetadata, + publicRoomoteMcp, taskRunsRouter, artifactsRouter, taskArtifactsRouter, @@ -211,6 +213,8 @@ export function createApiApp(): ApiApp { app.route('/api/inference', inference); app.route('/api/mcp', mcp); app.route('/api/mcp-routing', mcpRouting); + app.route('/mcp', publicRoomoteMcp); + app.route('/', mcpOAuthMetadata); app.route('/api/task-runs', taskRunsRouter); app.route('/api/artifacts', artifactsRouter); app.route('/api/tasks', taskArtifactsRouter); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 0248fcc3f..f2b872b1e 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -1,4 +1,8 @@ -import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import type { + AuthTokenContext, + McpAccessTokenContext, + RunTokenContext, +} from '@roomote/types'; export type WebhookResponse = { status: 'ok' | 'error'; @@ -13,6 +17,10 @@ export type CiE2eAuthContext = { }; export type Variables = { - authContext: AuthTokenContext | RunTokenContext | undefined; + authContext: + | AuthTokenContext + | McpAccessTokenContext + | RunTokenContext + | undefined; ciE2eAuth: CiE2eAuthContext | undefined; }; diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 00dc1a7da..b226421a3 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -135,6 +135,7 @@ "pages": [ "integrations/index", "integrations/custom-mcp-servers", + "integrations/roomote-mcp", "integrations/asana", "integrations/better-stack", "integrations/braintrust", diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 6e62350e0..f143a04ff 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -89,6 +89,12 @@ Use deployment or user-linked integrations when the tool is broadly useful across teams. Use an environment-level MCP server when the tool only makes sense for one workspace, repository set, or self-hosted service. +To use Roomote itself from an external OAuth-capable MCP client, connect the +[Roomote MCP](/integrations/roomote-mcp). It can inspect Roomote and connected +chat context, search and read tasks, list launch environments, launch or cancel +tasks, and send follow-up messages. Its browser-issued credential does not grant +general API or admin access. + ## A practical order For most teams, this order works well: diff --git a/apps/docs/integrations/roomote-mcp.mdx b/apps/docs/integrations/roomote-mcp.mdx new file mode 100644 index 000000000..69f211c2c --- /dev/null +++ b/apps/docs/integrations/roomote-mcp.mdx @@ -0,0 +1,78 @@ +--- +title: Roomote MCP +icon: plug-circle-bolt +description: Connect an OAuth-capable MCP client to Roomote's member task tools. +--- + +Roomote exposes member task management, environment discovery, and connected +chat context as a remote MCP server. OAuth-capable clients can connect without +manually creating or copying an API token. + +## Prerequisites + +Before connecting a client, `R_PUBLIC_URL`, or `R_APP_URL` when no public URL +is set, must be a browser-reachable HTTPS origin. The web and API services must +also share Redis and the same Roomote signing keys. + +Loopback HTTP callback URLs are accepted for desktop clients. Other client +callback URLs must use HTTPS. + +## Connect a client + +Configure the MCP client with this server URL: + +```text +/mcp +``` + +For example, if Roomote is available at `https://roomote.example`, use: + +```text +https://roomote.example/mcp +``` + +For Claude Code, add it at user scope and start the browser login: + +```bash +claude mcp add --transport http roomote --scope user /mcp +claude mcp login roomote +``` + +The client discovers the OAuth authorization server automatically. Roomote +opens the browser, asks the user to sign in when needed, and returns the client +to its registered callback after the user explicitly approves access. The +client must support OAuth authorization code flow with S256 PKCE and dynamic +client registration. + +Roomote issues one-hour access tokens and rotates an opaque refresh token for +up to 30 days. The client returns to browser authorization when that session +expires or is revoked. + +## Integration boundary + +The browser-issued credential is intentionally narrower than Roomote's +internal user and task-run credentials: + +- it is valid only for the advertised Roomote MCP resource URL +- it grants only the `mcp:roomote` scope +- it cannot authenticate tRPC, task, artifact, inference, integration proxy, + or other MCP endpoints +- authorization codes are single-use, expire after five minutes, and are + bound to the client ID, redirect URI, resource, and PKCE challenge +- anonymous client registrations must complete OAuth within one hour; clients + that complete the exchange remain registered for 30 days and can use only + HTTPS or loopback HTTP callback URLs + +The endpoint acts as the signed-in member. It can inspect Roomote and connected +chat context, search and read tasks, list launch environments, launch or cancel +tasks, and send follow-up messages. Existing member and admin authorization +still applies to every operation; the OAuth token does not grant a task-run or +administrator identity. + +## Troubleshooting + +If the client does not open a browser, verify that it follows the +`WWW-Authenticate` protected-resource metadata link returned by the MCP +endpoint. If the browser opens but authorization fails, confirm that the +client sends the exact resource URL above in both authorization and token +requests and uses the `mcp:roomote` scope. diff --git a/apps/web/src/app/.well-known/oauth-authorization-server/__tests__/route.test.ts b/apps/web/src/app/.well-known/oauth-authorization-server/__tests__/route.test.ts new file mode 100644 index 000000000..8e11b61a5 --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-authorization-server/__tests__/route.test.ts @@ -0,0 +1,25 @@ +const mockBootstrapWebRuntimeEnv = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: mockBootstrapWebRuntimeEnv, +})); + +import { GET } from '../route'; + +describe('Roomote OAuth authorization server metadata', () => { + it('advertises rotating refresh tokens and revocation', async () => { + mockBootstrapWebRuntimeEnv.mockResolvedValue({ + R_PUBLIC_URL: 'https://roomote.example', + R_APP_URL: 'http://localhost:3000', + }); + + const response = await GET(); + + await expect(response.json()).resolves.toMatchObject({ + issuer: 'https://roomote.example', + grant_types_supported: ['authorization_code', 'refresh_token'], + revocation_endpoint: + 'https://roomote.example/api/mcp-remote-oauth/revoke', + }); + }); +}); diff --git a/apps/web/src/app/.well-known/oauth-authorization-server/route.ts b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 000000000..bd9e25742 --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; + +import { ROOMOTE_MCP_SCOPE } from '@roomote/auth'; + +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + const env = await bootstrapWebRuntimeEnv(); + const issuer = new URL(getPublicAppUrl(env)).origin; + + return NextResponse.json( + { + issuer, + authorization_endpoint: `${issuer}/api/mcp-remote-oauth/authorize`, + token_endpoint: `${issuer}/api/mcp-remote-oauth/token`, + revocation_endpoint: `${issuer}/api/mcp-remote-oauth/revoke`, + registration_endpoint: `${issuer}/api/mcp-remote-oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none'], + code_challenge_methods_supported: ['S256'], + scopes_supported: [ROOMOTE_MCP_SCOPE], + }, + { headers: { 'Cache-Control': 'public, max-age=3600' } }, + ); +} diff --git a/apps/web/src/app/.well-known/oauth-protected-resource/mcp/route.ts b/apps/web/src/app/.well-known/oauth-protected-resource/mcp/route.ts new file mode 100644 index 000000000..f733b25f3 --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-protected-resource/mcp/route.ts @@ -0,0 +1,7 @@ +import { proxyRemoteMcpRequest } from '@/lib/server/remote-mcp-proxy'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET = (request: Parameters[0]) => + proxyRemoteMcpRequest(request, 'metadata'); diff --git a/apps/web/src/app/api/mcp-remote-oauth/authorize/__tests__/route.test.ts b/apps/web/src/app/api/mcp-remote-oauth/authorize/__tests__/route.test.ts new file mode 100644 index 000000000..6eb423d2e --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/__tests__/route.test.ts @@ -0,0 +1,156 @@ +import { NextRequest } from 'next/server'; + +const { + mockAuthorize, + mockGetClient, + mockCreateCode, + mockCreateConsentToken, + mockConsumeConsentToken, +} = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockGetClient: vi.fn(), + mockCreateCode: vi.fn(), + mockCreateConsentToken: vi.fn(), + mockConsumeConsentToken: vi.fn(), +})); + +vi.mock('@/lib/server', () => ({ authorize: mockAuthorize })); +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: async () => ({ + R_APP_URL: 'https://roomote.example', + TRPC_URL: 'https://api.example.com', + }), +})); +vi.mock('@/lib/server/mcp-remote-oauth', () => ({ + getRemoteMcpOAuthClient: mockGetClient, + createRemoteMcpAuthorizationCode: mockCreateCode, + createRemoteMcpConsentToken: mockCreateConsentToken, + consumeRemoteMcpConsentToken: mockConsumeConsentToken, +})); + +import { GET, POST } from '../route'; + +const clientId = '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568'; +const redirectUri = 'https://client.example/callback'; + +function authorizeRequest(options?: { + approved?: boolean; + consentToken?: string; +}) { + const url = new URL('https://roomote.example/api/mcp-remote-oauth/authorize'); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('state', 'client-state'); + url.searchParams.set('code_challenge', 'a'.repeat(43)); + url.searchParams.set('code_challenge_method', 'S256'); + url.searchParams.set('resource', 'https://roomote.example/mcp'); + url.searchParams.set('scope', 'mcp:roomote'); + if (!options?.approved) return new NextRequest(url); + + return new NextRequest(url, { + method: 'POST', + body: new URLSearchParams({ + ...(options.consentToken ? { consent_token: options.consentToken } : {}), + }), + }); +} + +describe('GET /api/mcp-remote-oauth/authorize', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetClient.mockResolvedValue({ + clientId, + clientName: 'Claude Code', + redirectUris: [redirectUri], + }); + mockCreateConsentToken.mockResolvedValue('consent-token'); + mockConsumeConsentToken.mockResolvedValue(true); + }); + + it('continues through browser sign-in before issuing a code', async () => { + mockAuthorize.mockResolvedValue({ success: false }); + + const response = await GET(authorizeRequest()); + const location = new URL(response.headers.get('location')!); + + expect(location.origin).toBe('https://roomote.example'); + expect(location.pathname).toBe('/sign-in'); + expect(location.searchParams.get('redirect_url')).toContain( + '/api/mcp-remote-oauth/authorize?', + ); + expect(mockCreateCode).not.toHaveBeenCalled(); + }); + + it('requires explicit approval before issuing a code', async () => { + mockAuthorize.mockResolvedValue({ success: true, userId: 'user-1' }); + + const response = await GET(authorizeRequest()); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(html).toContain('Authorize Claude Code?'); + expect(html).toContain('Allow access'); + expect(html).toContain('/logos/roomote-wordmark.svg'); + expect(html).toContain( + 'This gives Claude Code access to Roomote using your account.', + ); + expect(html).toContain('Claude Code will be able to:'); + expect(html).toContain('name="consent_token" value="consent-token"'); + expect(response.headers.get('content-security-policy')).toContain( + "form-action 'self' https://client.example", + ); + expect(response.headers.get('content-security-policy')).toContain( + "img-src 'self'", + ); + expect(mockCreateConsentToken).toHaveBeenCalledWith({ + userId: 'user-1', + requestTarget: expect.stringContaining( + '/api/mcp-remote-oauth/authorize?', + ), + }); + expect(mockCreateCode).not.toHaveBeenCalled(); + }); + + it('issues a resource-bound code after approval', async () => { + mockAuthorize.mockResolvedValue({ success: true, userId: 'user-1' }); + mockCreateCode.mockResolvedValue('authorization-code'); + + const response = await POST( + authorizeRequest({ approved: true, consentToken: 'consent-token' }), + ); + const location = new URL(response.headers.get('location')!); + + expect(response.status).toBe(303); + expect(location.toString()).toBe( + 'https://client.example/callback?code=authorization-code&state=client-state', + ); + expect(mockCreateCode).toHaveBeenCalledWith({ + userId: 'user-1', + clientId, + redirectUri, + codeChallenge: 'a'.repeat(43), + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }); + expect(mockConsumeConsentToken).toHaveBeenCalledWith('consent-token', { + userId: 'user-1', + requestTarget: expect.stringContaining( + '/api/mcp-remote-oauth/authorize?', + ), + }); + }); + + it('rejects approval POSTs without the one-time consent token', async () => { + mockAuthorize.mockResolvedValue({ success: true, userId: 'user-1' }); + + const response = await POST(authorizeRequest({ approved: true })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_request', + }); + expect(mockCreateCode).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts new file mode 100644 index 000000000..b63cbd044 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -0,0 +1,366 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE } from '@roomote/auth'; + +import { authorize } from '@/lib/server'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; +import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; +import { + createRemoteMcpAuthorizationCode, + createRemoteMcpConsentToken, + consumeRemoteMcpConsentToken, + getRemoteMcpOAuthClient, +} from '@/lib/server/mcp-remote-oauth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const authorizeSchema = z.object({ + response_type: z.literal('code'), + client_id: z.string().uuid(), + redirect_uri: z.string().url(), + state: z.string().min(1), + code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + code_challenge_method: z.literal('S256'), + resource: z.string().url(), + scope: z.string().optional(), +}); + +function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); +} + +function consentResponse(options: { + request: NextRequest; + clientName?: string; + consentToken: string; + redirectUri: string; +}) { + const action = escapeHtml( + `${options.request.nextUrl.pathname}${options.request.nextUrl.search}`, + ); + const clientName = escapeHtml(options.clientName ?? 'An MCP client'); + const callbackUrl = new URL(options.redirectUri); + const callbackHost = escapeHtml(callbackUrl.host); + const consentToken = escapeHtml(options.consentToken); + + return new NextResponse( + ` + + + + + Authorize ${clientName} + + + +
+
+
+ Roomote +
+

MCP access request

+

Authorize ${clientName}?

+

This gives ${clientName} access to Roomote using your account.

+
+

${clientName} will be able to:

+
    +
  • Read your task and chat context
  • +
  • Launch and cancel tasks
  • +
  • Send follow-up messages
  • +
+
+

After approval, you’ll return to ${callbackHost}.

+
+ + +
+

Only continue if you trust this application.

+
+
+
+
+ +`, + { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': `default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; form-action 'self' ${callbackUrl.origin}; base-uri 'none'; frame-ancestors 'none'`, + 'X-Frame-Options': 'DENY', + }, + }, + ); +} + +async function handleAuthorize(request: NextRequest, approved: boolean) { + const env = await bootstrapWebRuntimeEnv(); + const webUrl = getPublicAppUrl(env); + const parsed = authorizeSchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_request' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } }, + ); + } + + const input = parsed.data; + const client = await getRemoteMcpOAuthClient(input.client_id); + if (!client || !client.redirectUris.includes(input.redirect_uri)) { + return NextResponse.json( + { error: 'invalid_request' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } }, + ); + } + + const expectedResource = getRoomoteMcpResourceUrl( + env.R_PUBLIC_URL ?? env.R_APP_URL, + ); + const scopes = (input.scope ?? ROOMOTE_MCP_SCOPE) + .split(/\s+/) + .filter(Boolean); + if ( + input.resource !== expectedResource || + scopes.length !== 1 || + scopes[0] !== ROOMOTE_MCP_SCOPE + ) { + const redirect = new URL(input.redirect_uri); + redirect.searchParams.set('error', 'invalid_scope'); + redirect.searchParams.set('state', input.state); + return NextResponse.redirect(redirect); + } + + const auth = await authorize(); + if (!auth.success) { + const signInUrl = new URL('/sign-in', webUrl); + signInUrl.searchParams.set( + 'redirect_url', + `${request.nextUrl.pathname}${request.nextUrl.search}`, + ); + return NextResponse.redirect(signInUrl); + } + + const consentBinding = { + userId: auth.userId, + requestTarget: `${request.nextUrl.pathname}${request.nextUrl.search}`, + }; + + if (!approved) { + const consentToken = await createRemoteMcpConsentToken(consentBinding); + return consentResponse({ + request, + clientName: client.clientName, + consentToken, + redirectUri: input.redirect_uri, + }); + } + + let consentToken: FormDataEntryValue | null; + try { + consentToken = (await request.formData()).get('consent_token'); + } catch { + consentToken = null; + } + if ( + typeof consentToken !== 'string' || + !(await consumeRemoteMcpConsentToken(consentToken, consentBinding)) + ) { + return NextResponse.json( + { error: 'invalid_request' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } }, + ); + } + + const code = await createRemoteMcpAuthorizationCode({ + userId: auth.userId, + clientId: input.client_id, + redirectUri: input.redirect_uri, + codeChallenge: input.code_challenge, + resource: input.resource, + scopes, + }); + const redirect = new URL(input.redirect_uri); + redirect.searchParams.set('code', code); + redirect.searchParams.set('state', input.state); + return NextResponse.redirect(redirect, 303); +} + +export function GET(request: NextRequest) { + return handleAuthorize(request, false); +} + +export function POST(request: NextRequest) { + return handleAuthorize(request, true); +} diff --git a/apps/web/src/app/api/mcp-remote-oauth/register/__tests__/route.test.ts b/apps/web/src/app/api/mcp-remote-oauth/register/__tests__/route.test.ts new file mode 100644 index 000000000..3f93b8c77 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/register/__tests__/route.test.ts @@ -0,0 +1,140 @@ +import { NextRequest } from 'next/server'; + +const { mockRegistrationAllowed, mockRegisterClient } = vi.hoisted(() => ({ + mockRegistrationAllowed: vi.fn(), + mockRegisterClient: vi.fn(), +})); + +vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ + ...(await importOriginal()), + isRemoteMcpRegistrationAllowed: mockRegistrationAllowed, + registerRemoteMcpOAuthClient: mockRegisterClient, +})); + +import { POST } from '../route'; + +function registrationRequest( + redirectUri: string, + grantTypes: string[] = ['authorization_code'], +) { + return new NextRequest( + 'https://roomote.example/api/mcp-remote-oauth/register', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Test client', + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + grant_types: grantTypes, + }), + }, + ); +} + +describe('POST /api/mcp-remote-oauth/register', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRegistrationAllowed.mockResolvedValue(true); + }); + + it('registers an HTTPS callback for a public client', async () => { + mockRegisterClient.mockResolvedValue({ + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + clientName: 'Test client', + redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], + }); + + const response = await POST( + registrationRequest('https://client.example/callback'), + ); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + client_id: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + token_endpoint_auth_method: 'none', + }); + expect(mockRegistrationAllowed).toHaveBeenCalledWith( + JSON.stringify({ + clientName: 'Test client', + redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], + }), + ); + }); + + it('accepts clients that advertise refresh-token fallback support', async () => { + mockRegisterClient.mockResolvedValue({ + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + clientName: 'Test client', + redirectUris: ['http://localhost:54545/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + }); + + const response = await POST( + registrationRequest('http://localhost:54545/callback', [ + 'authorization_code', + 'refresh_token', + ]), + ); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + grant_types: ['authorization_code', 'refresh_token'], + }); + }); + + it('rejects a non-loopback HTTP callback', async () => { + const response = await POST( + registrationRequest('http://client.example/callback'), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_client_metadata', + }); + expect(mockRegisterClient).not.toHaveBeenCalled(); + expect(mockRegistrationAllowed).not.toHaveBeenCalled(); + }); + + it('rate limits anonymous client registration before writing Redis', async () => { + mockRegistrationAllowed.mockResolvedValue(false); + + const response = await POST( + registrationRequest('https://client.example/callback'), + ); + + expect(response.status).toBe(429); + expect(response.headers.get('retry-after')).toBe('3600'); + await expect(response.json()).resolves.toEqual({ + error: 'temporarily_unavailable', + }); + expect(mockRegisterClient).not.toHaveBeenCalled(); + }); + + it('returns a temporary error when the registered-client cap is full', async () => { + mockRegisterClient.mockRejectedValue(new Error('capacity reached')); + + const response = await POST( + registrationRequest('https://client.example/callback'), + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: 'temporarily_unavailable', + }); + }); + + it('rejects oversized redirect URI values before storing a client', async () => { + const response = await POST( + registrationRequest(`https://client.example/${'a'.repeat(2_100)}`), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_client_metadata', + }); + expect(mockRegisterClient).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/mcp-remote-oauth/register/route.ts b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts new file mode 100644 index 000000000..914cc9f98 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { + isAllowedOAuthRedirectUri, + isRemoteMcpRegistrationAllowed, + registerRemoteMcpOAuthClient, +} from '@/lib/server/mcp-remote-oauth'; + +export const runtime = 'nodejs'; + +const registrationSchema = z.object({ + client_name: z.string().trim().min(1).max(200).optional(), + redirect_uris: z + .array(z.string().max(1_024)) + .min(1) + .max(5) + .refine((values) => values.every(isAllowedOAuthRedirectUri)), + token_endpoint_auth_method: z.literal('none').optional(), + grant_types: z + .array(z.enum(['authorization_code', 'refresh_token'])) + .min(1) + .max(2) + .refine((values) => values.includes('authorization_code')) + .optional(), + response_types: z.array(z.literal('code')).optional(), +}); + +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: 'invalid_client_metadata' }, + { status: 400 }, + ); + } + + const parsed = registrationSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'invalid_client_metadata' }, + { status: 400 }, + ); + } + + const registrationFingerprint = JSON.stringify({ + clientName: parsed.data.client_name, + redirectUris: parsed.data.redirect_uris, + grantTypes: parsed.data.grant_types, + }); + try { + if (!(await isRemoteMcpRegistrationAllowed(registrationFingerprint))) { + return NextResponse.json( + { error: 'temporarily_unavailable' }, + { status: 429, headers: { 'Retry-After': '3600' } }, + ); + } + } catch { + return NextResponse.json( + { error: 'temporarily_unavailable' }, + { status: 503 }, + ); + } + + let client; + try { + client = await registerRemoteMcpOAuthClient({ + clientName: parsed.data.client_name, + redirectUris: parsed.data.redirect_uris, + grantTypes: parsed.data.grant_types, + }); + } catch { + return NextResponse.json( + { error: 'temporarily_unavailable' }, + { status: 503 }, + ); + } + + return NextResponse.json( + { + client_id: client.clientId, + client_name: client.clientName, + redirect_uris: client.redirectUris, + token_endpoint_auth_method: 'none', + grant_types: client.grantTypes, + response_types: ['code'], + }, + { status: 201, headers: { 'Cache-Control': 'no-store' } }, + ); +} diff --git a/apps/web/src/app/api/mcp-remote-oauth/revoke/__tests__/route.test.ts b/apps/web/src/app/api/mcp-remote-oauth/revoke/__tests__/route.test.ts new file mode 100644 index 000000000..fd3571ca6 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/revoke/__tests__/route.test.ts @@ -0,0 +1,44 @@ +import { NextRequest } from 'next/server'; + +const mockRevokeRefreshSession = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/server/mcp-remote-oauth', () => ({ + revokeRemoteMcpRefreshSession: mockRevokeRefreshSession, +})); + +import { POST } from '../route'; + +function revocationRequest(token = 'refresh-token') { + return new NextRequest( + 'https://roomote.example/api/mcp-remote-oauth/revoke', + { + method: 'POST', + body: new URLSearchParams({ + token, + client_id: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + }), + }, + ); +} + +describe('POST /api/mcp-remote-oauth/revoke', () => { + beforeEach(() => vi.clearAllMocks()); + + it('revokes a refresh session', async () => { + const response = await POST(revocationRequest()); + + expect(response.status).toBe(200); + expect(mockRevokeRefreshSession).toHaveBeenCalledWith( + 'refresh-token', + '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + ); + }); + + it('returns success for an unknown token', async () => { + mockRevokeRefreshSession.mockResolvedValue(undefined); + + const response = await POST(revocationRequest('unknown-token')); + + expect(response.status).toBe(200); + }); +}); diff --git a/apps/web/src/app/api/mcp-remote-oauth/revoke/route.ts b/apps/web/src/app/api/mcp-remote-oauth/revoke/route.ts new file mode 100644 index 000000000..2e82246db --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/revoke/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { revokeRemoteMcpRefreshSession } from '@/lib/server/mcp-remote-oauth'; + +export const runtime = 'nodejs'; + +const revocationSchema = z.object({ + token: z.string().min(1), + client_id: z.string().uuid(), +}); + +export async function POST(request: NextRequest) { + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); + } + const parsed = revocationSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) { + return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); + } + + await revokeRemoteMcpRefreshSession(parsed.data.token, parsed.data.client_id); + return new NextResponse(null, { + status: 200, + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/apps/web/src/app/api/mcp-remote-oauth/token/__tests__/route.test.ts b/apps/web/src/app/api/mcp-remote-oauth/token/__tests__/route.test.ts new file mode 100644 index 000000000..625e3640c --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/token/__tests__/route.test.ts @@ -0,0 +1,262 @@ +import { createHash } from 'node:crypto'; +import { NextRequest } from 'next/server'; + +const { + mockGetCode, + mockConsumeCode, + mockPromoteClient, + mockCreateRefreshSession, + mockGetRefreshSession, + mockRotateRefreshToken, + mockGetClient, + mockCreateToken, + mockBootstrapWebRuntimeEnv, +} = vi.hoisted(() => ({ + mockGetCode: vi.fn(), + mockConsumeCode: vi.fn(), + mockPromoteClient: vi.fn(), + mockCreateRefreshSession: vi.fn(), + mockGetRefreshSession: vi.fn(), + mockRotateRefreshToken: vi.fn(), + mockGetClient: vi.fn(), + mockCreateToken: vi.fn(), + mockBootstrapWebRuntimeEnv: vi.fn(), +})); + +vi.mock('@roomote/auth', async (importOriginal) => ({ + ...(await importOriginal()), + createMcpAccessToken: mockCreateToken, +})); + +vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ + ...(await importOriginal()), + getRemoteMcpAuthorizationCode: mockGetCode, + consumeRemoteMcpAuthorizationCode: mockConsumeCode, + promoteRemoteMcpOAuthClient: mockPromoteClient, + createRemoteMcpRefreshSession: mockCreateRefreshSession, + getRemoteMcpRefreshSession: mockGetRefreshSession, + rotateRemoteMcpRefreshToken: mockRotateRefreshToken, + getRemoteMcpOAuthClient: mockGetClient, +})); + +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: mockBootstrapWebRuntimeEnv, +})); + +import { POST } from '../route'; + +const verifier = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; +const challenge = createHash('sha256').update(verifier).digest('base64url'); +const resource = 'https://api.example.com/mcp'; + +function tokenRequest(overrides: Record = {}) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authorization-code', + client_id: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + redirect_uri: 'https://client.example/callback', + code_verifier: verifier, + resource, + ...overrides, + }); + return new NextRequest('https://roomote.example/api/mcp-remote-oauth/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); +} + +function refreshRequest(overrides: Record = {}) { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: 'session.refresh-token', + client_id: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + resource, + ...overrides, + }); + return new NextRequest('https://roomote.example/api/mcp-remote-oauth/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); +} + +describe('POST /api/mcp-remote-oauth/token', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetCode.mockResolvedValue({ + userId: 'user-1', + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + redirectUri: 'https://client.example/callback', + codeChallenge: challenge, + resource, + scopes: ['mcp:roomote'], + }); + mockConsumeCode.mockResolvedValue(true); + mockPromoteClient.mockResolvedValue(true); + mockCreateRefreshSession.mockResolvedValue('initial-refresh-token'); + mockGetRefreshSession.mockResolvedValue({ + sessionId: 'session', + userId: 'user-1', + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + resource, + scopes: ['mcp:roomote'], + currentTokenHash: 'token-hash', + expiresAt: Math.floor(Date.now() / 1000) + 3_600, + }); + mockRotateRefreshToken.mockResolvedValue({ + status: 'ok', + refreshToken: 'rotated-refresh-token', + }); + mockGetClient.mockResolvedValue({ + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code', 'refresh_token'], + }); + mockCreateToken.mockResolvedValue('access-token'); + mockBootstrapWebRuntimeEnv.mockResolvedValue({}); + }); + + it('exchanges a bound PKCE code for a short-lived MCP token', async () => { + const response = await POST(tokenRequest()); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ + access_token: 'access-token', + refresh_token: 'initial-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:roomote', + }); + expect(mockCreateToken).toHaveBeenCalledWith({ + userId: 'user-1', + resource, + scopes: ['mcp:roomote'], + timeoutMs: 3_600_000, + }); + expect(mockBootstrapWebRuntimeEnv).toHaveBeenCalledOnce(); + expect(mockConsumeCode).toHaveBeenCalledWith( + 'authorization-code', + expect.objectContaining({ userId: 'user-1' }), + ); + expect(mockPromoteClient).toHaveBeenCalledWith( + '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + 'user-1', + ); + expect(mockCreateRefreshSession).toHaveBeenCalledWith({ + userId: 'user-1', + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + resource, + scopes: ['mcp:roomote'], + }); + }); + + it('rotates a refresh token and issues a new access token', async () => { + const response = await POST(refreshRequest()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + access_token: 'access-token', + refresh_token: 'rotated-refresh-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:roomote', + }); + expect(mockRotateRefreshToken).toHaveBeenCalledWith( + 'session.refresh-token', + expect.objectContaining({ userId: 'user-1' }), + ); + }); + + it('does not issue a refresh token to an authorization-code-only client', async () => { + mockGetClient.mockResolvedValue({ + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], + }); + + const response = await POST(tokenRequest()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:roomote', + }); + expect(mockCreateRefreshSession).not.toHaveBeenCalled(); + }); + + it('does not burn a refresh token when the resource binding is wrong', async () => { + const response = await POST( + refreshRequest({ resource: 'https://other.example/mcp' }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); + expect(mockRotateRefreshToken).not.toHaveBeenCalled(); + expect(mockCreateToken).not.toHaveBeenCalled(); + }); + + it('rejects a refresh token whose rotation detects reuse', async () => { + mockRotateRefreshToken.mockResolvedValue({ status: 'reuse' }); + + const response = await POST(refreshRequest()); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); + }); + + it('rejects a verifier that does not match the authorization code', async () => { + const response = await POST( + tokenRequest({ + code_verifier: + 'zyxwvutsrqponmlkjihgfedcbaABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~', + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); + expect(mockConsumeCode).not.toHaveBeenCalled(); + expect(mockCreateToken).not.toHaveBeenCalled(); + }); + + it('rejects an exchange when its pending client registration expired', async () => { + mockPromoteClient.mockResolvedValue(false); + + const response = await POST(tokenRequest()); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); + expect(mockConsumeCode).not.toHaveBeenCalled(); + expect(mockCreateToken).not.toHaveBeenCalled(); + }); + + it('returns an OAuth error for a malformed request body', async () => { + const response = await POST( + new NextRequest('https://roomote.example/api/mcp-remote-oauth/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_request', + }); + expect(mockGetCode).not.toHaveBeenCalled(); + }); + + it('requires the token request to repeat the bound resource', async () => { + const response = await POST(tokenRequest({ resource: '' })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'invalid_request', + }); + expect(mockGetCode).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/mcp-remote-oauth/token/route.ts b/apps/web/src/app/api/mcp-remote-oauth/token/route.ts new file mode 100644 index 000000000..441421e64 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/token/route.ts @@ -0,0 +1,156 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { + createMcpAccessToken, + DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + ROOMOTE_MCP_SCOPE, +} from '@roomote/auth'; + +import { + consumeRemoteMcpAuthorizationCode, + createRemoteMcpRefreshSession, + getRemoteMcpAuthorizationCode, + getRemoteMcpOAuthClient, + getRemoteMcpRefreshSession, + promoteRemoteMcpOAuthClient, + rotateRemoteMcpRefreshToken, + verifyPkceChallenge, +} from '@/lib/server/mcp-remote-oauth'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; + +export const runtime = 'nodejs'; + +const tokenSchema = z.discriminatedUnion('grant_type', [ + z.object({ + grant_type: z.literal('authorization_code'), + code: z.string().min(1), + client_id: z.string().uuid(), + redirect_uri: z.string().url(), + code_verifier: z.string().regex(/^[A-Za-z0-9._~-]{43,128}$/), + resource: z.string().url(), + }), + z.object({ + grant_type: z.literal('refresh_token'), + refresh_token: z.string().min(1), + client_id: z.string().uuid(), + resource: z.string().url(), + scope: z.string().optional(), + }), +]); + +function oauthError(error: string) { + return NextResponse.json( + { error }, + { status: 400, headers: { 'Cache-Control': 'no-store' } }, + ); +} + +function tokenResponse(accessToken: string, refreshToken?: string) { + return NextResponse.json( + { + access_token: accessToken, + ...(refreshToken ? { refresh_token: refreshToken } : {}), + token_type: 'Bearer', + expires_in: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS / 1000, + scope: ROOMOTE_MCP_SCOPE, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} + +export async function POST(request: NextRequest) { + let form: FormData; + try { + form = await request.formData(); + } catch { + return oauthError('invalid_request'); + } + + const parsed = tokenSchema.safeParse(Object.fromEntries(form)); + if (!parsed.success) { + return oauthError('invalid_request'); + } + + const input = parsed.data; + await bootstrapWebRuntimeEnv(); + + if (input.grant_type === 'refresh_token') { + const [session, client] = await Promise.all([ + getRemoteMcpRefreshSession(input.refresh_token), + getRemoteMcpOAuthClient(input.client_id), + ]); + if ( + !session || + !client || + !client.grantTypes.includes('refresh_token') || + session.clientId !== input.client_id || + session.resource !== input.resource || + session.scopes.length !== 1 || + session.scopes[0] !== ROOMOTE_MCP_SCOPE || + (input.scope !== undefined && input.scope !== ROOMOTE_MCP_SCOPE) + ) { + return oauthError('invalid_grant'); + } + + const accessToken = await createMcpAccessToken({ + userId: session.userId, + resource: session.resource, + scopes: [ROOMOTE_MCP_SCOPE], + timeoutMs: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + }); + const rotation = await rotateRemoteMcpRefreshToken( + input.refresh_token, + session, + ); + if (rotation.status !== 'ok') return oauthError('invalid_grant'); + return tokenResponse(accessToken, rotation.refreshToken); + } + + const authorization = await getRemoteMcpAuthorizationCode(input.code); + const client = authorization + ? await getRemoteMcpOAuthClient(authorization.clientId) + : null; + if ( + !authorization || + !client || + authorization.clientId !== input.client_id || + authorization.redirectUri !== input.redirect_uri || + input.resource !== authorization.resource || + authorization.scopes.length !== 1 || + authorization.scopes[0] !== ROOMOTE_MCP_SCOPE || + !verifyPkceChallenge(input.code_verifier, authorization.codeChallenge) + ) { + return oauthError('invalid_grant'); + } + + if ( + !(await promoteRemoteMcpOAuthClient( + authorization.clientId, + authorization.userId, + )) + ) { + return oauthError('invalid_grant'); + } + + if (!(await consumeRemoteMcpAuthorizationCode(input.code, authorization))) { + return oauthError('invalid_grant'); + } + + const accessToken = await createMcpAccessToken({ + userId: authorization.userId, + resource: authorization.resource, + scopes: [ROOMOTE_MCP_SCOPE], + timeoutMs: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + }); + const refreshToken = client.grantTypes.includes('refresh_token') + ? await createRemoteMcpRefreshSession({ + userId: authorization.userId, + clientId: authorization.clientId, + resource: authorization.resource, + scopes: [ROOMOTE_MCP_SCOPE], + }) + : undefined; + + return tokenResponse(accessToken, refreshToken); +} diff --git a/apps/web/src/app/mcp/__tests__/route.test.ts b/apps/web/src/app/mcp/__tests__/route.test.ts new file mode 100644 index 000000000..7bb7d7005 --- /dev/null +++ b/apps/web/src/app/mcp/__tests__/route.test.ts @@ -0,0 +1,136 @@ +import { NextRequest } from 'next/server'; + +const mockBootstrapWebRuntimeEnv = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: mockBootstrapWebRuntimeEnv, +})); + +import { GET as GET_METADATA } from '../../.well-known/oauth-protected-resource/mcp/route'; +import { DELETE, GET, POST } from '../route'; + +describe('public Roomote MCP proxy', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockBootstrapWebRuntimeEnv.mockResolvedValue({ + R_PUBLIC_URL: 'https://roomote.example', + R_APP_URL: 'http://localhost:3000', + TRPC_URL: 'https://api.internal.test/_roomote-api', + }); + }); + + it('forwards the public MCP endpoint to the pathful API base', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('unauthorized', { + status: 401, + headers: { + 'www-authenticate': + 'Bearer resource_metadata="https://roomote.example/.well-known/oauth-protected-resource/mcp"', + }, + }), + ); + + const response = await GET( + new NextRequest('https://roomote.example/mcp', { + headers: { + host: 'attacker.example', + forwarded: 'host=attacker.example', + 'x-forwarded-for': '203.0.113.10', + 'x-forwarded-host': 'attacker.example', + 'x-forwarded-proto': 'http', + }, + }), + ); + + const [target, init] = fetchMock.mock.calls[0]!; + expect(String(target)).toBe('https://api.internal.test/_roomote-api/mcp'); + expect(init).toMatchObject({ method: 'GET', redirect: 'manual' }); + const forwardedHeaders = init?.headers as Headers; + expect(forwardedHeaders.has('host')).toBe(false); + expect(forwardedHeaders.has('forwarded')).toBe(false); + expect(forwardedHeaders.has('x-forwarded-for')).toBe(false); + expect(forwardedHeaders.get('x-forwarded-host')).toBe('roomote.example'); + expect(forwardedHeaders.get('x-forwarded-proto')).toBe('https'); + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toBe( + 'Bearer resource_metadata="https://roomote.example/.well-known/oauth-protected-resource/mcp"', + ); + }); + + it('forwards authenticated Streamable HTTP requests and bodies', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })); + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + }); + + const response = await POST( + new NextRequest('https://roomote.example/mcp', { + method: 'POST', + headers: { + authorization: 'Bearer access-token', + 'content-type': 'application/json', + }, + body, + }), + ); + + const [, init] = fetchMock.mock.calls[0]!; + const headers = init?.headers as Headers; + expect(headers.get('authorization')).toBe('Bearer access-token'); + expect(new TextDecoder().decode(init?.body as ArrayBuffer)).toBe(body); + expect(response.status).toBe(200); + }); + + it('forwards public protected-resource discovery to the API', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + resource: 'https://roomote.example/mcp', + authorization_servers: ['https://roomote.example'], + }), + ); + + const response = await GET_METADATA( + new NextRequest( + 'https://roomote.example/.well-known/oauth-protected-resource/mcp', + ), + ); + + expect(String(fetchMock.mock.calls[0]![0])).toBe( + 'https://api.internal.test/_roomote-api/.well-known/oauth-protected-resource/mcp', + ); + await expect(response.json()).resolves.toMatchObject({ + resource: 'https://roomote.example/mcp', + }); + }); + + it('streams DELETE responses and preserves MCP session headers', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('deleted')); + controller.close(); + }, + }), + { status: 200, headers: { 'mcp-session-id': 'session-2' } }, + ), + ); + + const response = await DELETE( + new NextRequest('https://roomote.example/mcp', { + method: 'DELETE', + headers: { 'mcp-session-id': 'session-1' }, + }), + ); + + const [, init] = fetchMock.mock.calls[0]!; + expect(init?.method).toBe('DELETE'); + expect((init?.headers as Headers).get('mcp-session-id')).toBe('session-1'); + expect(response.headers.get('mcp-session-id')).toBe('session-2'); + await expect(response.text()).resolves.toBe('deleted'); + }); +}); diff --git a/apps/web/src/app/mcp/route.ts b/apps/web/src/app/mcp/route.ts new file mode 100644 index 000000000..2734046a1 --- /dev/null +++ b/apps/web/src/app/mcp/route.ts @@ -0,0 +1,9 @@ +import { proxyRemoteMcpRequest } from '@/lib/server/remote-mcp-proxy'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const DELETE = (request: Parameters[0]) => + proxyRemoteMcpRequest(request, 'mcp'); +export const GET = DELETE; +export const POST = DELETE; diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts new file mode 100644 index 000000000..11efd77df --- /dev/null +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -0,0 +1,423 @@ +const redisState = vi.hoisted(() => new Map()); +const redisSortedSets = vi.hoisted( + () => new Map>(), +); + +vi.mock('@roomote/redis', () => ({ + getRedis: () => ({ + set: async (key: string, value: string) => { + redisState.set(key, value); + return 'OK'; + }, + get: async (key: string) => redisState.get(key) ?? null, + eval: async (script: string, keyCount: number, ...args: string[]) => { + const keys = args.slice(0, keyCount); + const values = args.slice(keyCount); + const key = keys[0]!; + if (script.includes("redis.call('ZCARD', KEYS[2])")) { + const indexKey = keys[1]!; + const [clientJson, , expiresAt, clientId, maxClients, now] = values; + const clients = redisSortedSets.get(indexKey) ?? new Map(); + for (const [id, expiry] of clients) { + if (expiry <= Number(now)) clients.delete(id); + } + if (clients.size >= Number(maxClients)) return 0; + redisState.set(key, clientJson!); + clients.set(clientId!, Number(expiresAt)); + redisSortedSets.set(indexKey, clients); + return 1; + } + + if (script.includes('local previousSessionId =')) { + const [sessionPrefix, refreshPrefix, sessionJson, marker, sessionId] = + values; + const previousSessionId = redisState.get(keys[2]!); + if (previousSessionId) { + const previousSessionKey = `${sessionPrefix}${previousSessionId}`; + const previous = redisState.get(previousSessionKey); + if (previous) { + const decoded = JSON.parse(previous) as { + currentTokenHash: string; + }; + redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); + } + redisState.delete(previousSessionKey); + } + redisState.set(key, sessionJson!); + redisState.set(keys[1]!, marker!); + redisState.set(keys[2]!, sessionId!); + return 1; + } + + if (script.includes("return {'reuse'}")) { + const [rotatedMarker, activeMarker, expected, next, refreshPrefix] = + values; + const marker = redisState.get(key); + if (marker === rotatedMarker) { + const session = redisState.get(keys[2]!); + if (session) { + const decoded = JSON.parse(session) as { + currentTokenHash: string; + }; + redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); + } + redisState.delete(keys[2]!); + return ['reuse']; + } + if (marker !== activeMarker || redisState.get(keys[2]!) !== expected) { + return ['invalid']; + } + redisState.set(key, rotatedMarker!); + redisState.set(keys[1]!, activeMarker!); + redisState.set(keys[2]!, next!); + return ['ok']; + } + + if (script.includes('decoded.clientId')) { + const [clientId, refreshPrefix, activeMarker, tokenHash] = values; + if (redisState.get(key) !== activeMarker) return 0; + const session = redisState.get(keys[1]!); + if (!session) return 0; + const decoded = JSON.parse(session) as { + clientId: string; + currentTokenHash: string; + }; + if ( + decoded.clientId !== clientId || + decoded.currentTokenHash !== tokenHash + ) { + return 0; + } + redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); + redisState.delete(key); + redisState.delete(keys[1]!); + return 1; + } + + if (script.includes('local client = tonumber')) { + const globalKey = keys[1]!; + const [, clientLimit, globalLimit] = values; + const clientCount = Number(redisState.get(key) ?? '0'); + const globalCount = Number(redisState.get(globalKey) ?? '0'); + if ( + clientCount >= Number(clientLimit) || + globalCount >= Number(globalLimit) + ) { + return 0; + } + redisState.set(key, String(clientCount + 1)); + redisState.set(globalKey, String(globalCount + 1)); + return 1; + } + + if (script.includes("redis.call('ZREM'")) { + const indexKey = keys[1]!; + const globalIndexKey = keys[2]!; + const userIndexKey = keys[3]!; + const clientId = values[1]!; + const now = Number(values[2]); + const expiresAt = Number(values[3]); + const globalLimit = Number(values[4]); + const userLimit = Number(values[5]); + const globalClients = redisSortedSets.get(globalIndexKey) ?? new Map(); + const userClients = redisSortedSets.get(userIndexKey) ?? new Map(); + for (const [id, expiry] of globalClients) { + if (expiry <= now) globalClients.delete(id); + } + for (const [id, expiry] of userClients) { + if (expiry <= now) userClients.delete(id); + } + if (!globalClients.has(clientId) && globalClients.size >= globalLimit) { + return 0; + } + if (!userClients.has(clientId) && userClients.size >= userLimit) { + return 0; + } + if (!redisState.has(key)) return 0; + redisSortedSets.get(indexKey)?.delete(clientId); + globalClients.set(clientId, expiresAt); + redisSortedSets.set(globalIndexKey, globalClients); + userClients.set(clientId, expiresAt); + redisSortedSets.set(userIndexKey, userClients); + return 1; + } + + if (script.includes("redis.call('GET'")) { + const value = redisState.get(key) ?? null; + if (value === values[0]) redisState.delete(key); + return value === values[0] ? value : null; + } + + const count = Number(redisState.get(key) ?? '0') + 1; + redisState.set(key, String(count)); + return count; + }, + }), +})); + +import { + consumeRemoteMcpAuthorizationCode, + consumeRemoteMcpConsentToken, + createRemoteMcpAuthorizationCode, + createRemoteMcpConsentToken, + createRemoteMcpRefreshSession, + getRemoteMcpAuthorizationCode, + getRemoteMcpRefreshSession, + isAllowedOAuthRedirectUri, + isRemoteMcpRegistrationAllowed, + promoteRemoteMcpOAuthClient, + registerRemoteMcpOAuthClient, + revokeRemoteMcpRefreshSession, + rotateRemoteMcpRefreshToken, + verifyPkceChallenge, +} from './mcp-remote-oauth'; + +describe('remote MCP OAuth state', () => { + beforeEach(() => { + redisState.clear(); + redisSortedSets.clear(); + }); + + it('accepts HTTPS and loopback redirects only', () => { + expect(isAllowedOAuthRedirectUri('https://client.example/callback')).toBe( + true, + ); + expect(isAllowedOAuthRedirectUri('http://127.0.0.1:43110/callback')).toBe( + true, + ); + expect(isAllowedOAuthRedirectUri('http://client.example/callback')).toBe( + false, + ); + }); + + it('stores registered client redirect URIs', async () => { + const client = await registerRemoteMcpOAuthClient({ + clientName: 'Test client', + redirectUris: ['https://client.example/callback'], + }); + + expect(client).toMatchObject({ + clientName: 'Test client', + redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], + }); + await expect( + promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), + ).resolves.toBe(true); + expect( + [...redisSortedSets.entries()].find(([key]) => + key.includes('registered-clients'), + )?.[1].size, + ).toBe(0); + }); + + it('consumes authorization codes once', async () => { + const value = { + userId: 'user-1', + clientId: 'client-1', + redirectUri: 'https://client.example/callback', + codeChallenge: 'challenge', + resource: 'https://api.example.com/mcp', + scopes: ['mcp:roomote'], + }; + const code = await createRemoteMcpAuthorizationCode(value); + + await expect(getRemoteMcpAuthorizationCode(code)).resolves.toEqual(value); + await expect(consumeRemoteMcpAuthorizationCode(code, value)).resolves.toBe( + true, + ); + await expect(consumeRemoteMcpAuthorizationCode(code, value)).resolves.toBe( + false, + ); + }); + + it('does not consume a code when the expected binding differs', async () => { + const value = { + userId: 'user-1', + clientId: 'client-1', + redirectUri: 'https://client.example/callback', + codeChallenge: 'challenge', + resource: 'https://api.example.com/mcp', + scopes: ['mcp:roomote'], + }; + const code = await createRemoteMcpAuthorizationCode(value); + + await expect( + consumeRemoteMcpAuthorizationCode(code, { + ...value, + codeChallenge: 'wrong-challenge', + }), + ).resolves.toBe(false); + await expect(getRemoteMcpAuthorizationCode(code)).resolves.toEqual(value); + }); + + it('binds consent approval to the user and authorization request', async () => { + const binding = { + userId: 'user-1', + requestTarget: '/api/mcp-remote-oauth/authorize?client_id=client-1', + }; + const token = await createRemoteMcpConsentToken(binding); + + await expect( + consumeRemoteMcpConsentToken(token, { + ...binding, + userId: 'attacker', + }), + ).resolves.toBe(false); + await expect(consumeRemoteMcpConsentToken(token, binding)).resolves.toBe( + true, + ); + await expect(consumeRemoteMcpConsentToken(token, binding)).resolves.toBe( + false, + ); + }); + + it('rotates refresh tokens and revokes the session on reuse', async () => { + const refreshToken = await createRemoteMcpRefreshSession({ + userId: 'user-1', + clientId: 'client-1', + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }); + const session = await getRemoteMcpRefreshSession(refreshToken); + expect(session).toMatchObject({ userId: 'user-1', clientId: 'client-1' }); + + const rotation = await rotateRemoteMcpRefreshToken(refreshToken, session!); + expect(rotation.status).toBe('ok'); + if (rotation.status !== 'ok') throw new Error('expected refresh rotation'); + await expect( + getRemoteMcpRefreshSession(rotation.refreshToken), + ).resolves.toMatchObject({ userId: 'user-1' }); + + await expect( + rotateRemoteMcpRefreshToken(refreshToken, session!), + ).resolves.toEqual({ status: 'reuse' }); + await expect( + getRemoteMcpRefreshSession(rotation.refreshToken), + ).resolves.toBeNull(); + }); + + it('isolates a replacement authorization from old-family replay', async () => { + const previousToken = await createRemoteMcpRefreshSession({ + userId: 'user-1', + clientId: 'client-1', + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }); + const previousSession = await getRemoteMcpRefreshSession(previousToken); + const previousRotation = await rotateRemoteMcpRefreshToken( + previousToken, + previousSession!, + ); + expect(previousRotation.status).toBe('ok'); + + const replacementToken = await createRemoteMcpRefreshSession({ + userId: 'user-1', + clientId: 'client-1', + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }); + const replacementSession = + await getRemoteMcpRefreshSession(replacementToken); + expect(replacementSession?.sessionId).not.toBe(previousSession?.sessionId); + if (previousRotation.status !== 'ok') { + throw new Error('expected previous refresh rotation'); + } + await expect( + getRemoteMcpRefreshSession(previousRotation.refreshToken), + ).resolves.toBeNull(); + + await expect( + rotateRemoteMcpRefreshToken(previousToken, previousSession!), + ).resolves.toEqual({ status: 'reuse' }); + await expect(getRemoteMcpRefreshSession(replacementToken)).resolves.toEqual( + replacementSession, + ); + }); + + it('revokes a refresh session by client ID', async () => { + const refreshToken = await createRemoteMcpRefreshSession({ + userId: 'user-1', + clientId: 'client-1', + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }); + + await revokeRemoteMcpRefreshSession(refreshToken, 'client-1'); + + await expect(getRemoteMcpRefreshSession(refreshToken)).resolves.toBeNull(); + }); + + it('does not revoke a refresh session with a forged token secret', async () => { + const refreshToken = await createRemoteMcpRefreshSession({ + userId: 'user-1', + clientId: 'client-1', + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }); + const [sessionId] = refreshToken.split('.'); + + await revokeRemoteMcpRefreshSession( + `${sessionId}.${'a'.repeat(43)}`, + 'client-1', + ); + + await expect( + getRemoteMcpRefreshSession(refreshToken), + ).resolves.toMatchObject({ userId: 'user-1', clientId: 'client-1' }); + }); + + it('bounds registrations per client and globally', async () => { + const allowed = []; + for (let index = 0; index < 21; index += 1) { + allowed.push(await isRemoteMcpRegistrationAllowed('same-client')); + } + + expect(allowed.slice(0, 20).every(Boolean)).toBe(true); + expect(allowed[20]).toBe(false); + expect( + [...redisState.entries()].find(([key]) => key.includes(':global:'))?.[1], + ).toBe('20'); + }); + + it('does not allocate client buckets after the global limit is full', async () => { + for (let index = 0; index < 100; index += 1) { + await expect( + isRemoteMcpRegistrationAllowed(`client-${index}`), + ).resolves.toBe(true); + } + const keyCountAtLimit = redisState.size; + + await expect( + isRemoteMcpRegistrationAllowed('overflow-client'), + ).resolves.toBe(false); + expect(redisState.size).toBe(keyCountAtLimit); + }); + + it('caps promoted clients per signed-in user', async () => { + for (let index = 0; index < 50; index += 1) { + const client = await registerRemoteMcpOAuthClient({ + redirectUris: [`https://client-${index}.example/callback`], + }); + await expect( + promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), + ).resolves.toBe(true); + } + const overflowClient = await registerRemoteMcpOAuthClient({ + redirectUris: ['https://overflow.example/callback'], + }); + + await expect( + promoteRemoteMcpOAuthClient(overflowClient.clientId, 'user-1'), + ).resolves.toBe(false); + }); + + it('verifies S256 PKCE challenges', () => { + expect( + verifyPkceChallenge( + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~', + 'ImpiCd8pp4MveCNnbIS7-GXEtB0xF5HMIDoWqvGA5ig', + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts new file mode 100644 index 000000000..ee168b688 --- /dev/null +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -0,0 +1,500 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import { getRedis } from '@roomote/redis'; + +const PENDING_CLIENT_TTL_SECONDS = 60 * 60; +const ACTIVE_CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; +const REFRESH_SESSION_TTL_SECONDS = ACTIVE_CLIENT_TTL_SECONDS; +const AUTHORIZATION_CODE_TTL_SECONDS = 5 * 60; +const CONSENT_TOKEN_TTL_SECONDS = 10 * 60; +const REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 60 * 60; +const REGISTRATION_RATE_LIMIT_PER_CLIENT = 20; +const REGISTRATION_RATE_LIMIT_GLOBAL = 100; +const MAX_REGISTERED_CLIENTS = 250; +const MAX_ACTIVE_CLIENTS_PER_USER = 50; +const MAX_ACTIVE_CLIENTS_GLOBAL = 10_000; +const CLIENT_KEY_PREFIX = 'mcp-remote-oauth:client:'; +const CODE_KEY_PREFIX = 'mcp-remote-oauth:code:'; +const CONSENT_KEY_PREFIX = 'mcp-remote-oauth:consent:'; +const REGISTRATION_RATE_KEY_PREFIX = 'mcp-remote-oauth:registration-rate:'; +const REGISTERED_CLIENTS_KEY = 'mcp-remote-oauth:registered-clients'; +const ACTIVE_CLIENTS_KEY = 'mcp-remote-oauth:active-clients'; +const ACTIVE_CLIENTS_USER_KEY_PREFIX = 'mcp-remote-oauth:active-clients:user:'; +const REFRESH_SESSION_KEY_PREFIX = 'mcp-remote-oauth:session:'; +const REFRESH_TOKEN_KEY_PREFIX = 'mcp-remote-oauth:refresh:'; +const REFRESH_CLIENT_SESSION_KEY_PREFIX = 'mcp-remote-oauth:client-session:'; + +const CONSUME_CODE_LUA = ` +local value = redis.call('GET', KEYS[1]) +if value == ARGV[1] then + redis.call('DEL', KEYS[1]) + return value +end +return false +`; + +const ADMIT_REGISTRATION_LUA = ` +local client = tonumber(redis.call('GET', KEYS[1]) or '0') +local global = tonumber(redis.call('GET', KEYS[2]) or '0') +if client >= tonumber(ARGV[2]) or global >= tonumber(ARGV[3]) then + return 0 +end +client = redis.call('INCR', KEYS[1]) +global = redis.call('INCR', KEYS[2]) +if client == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +if global == 1 then + redis.call('EXPIRE', KEYS[2], ARGV[1]) +end +return 1 +`; + +const REGISTER_CLIENT_LUA = ` +redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[6]) +if redis.call('ZCARD', KEYS[2]) >= tonumber(ARGV[5]) then + return 0 +end +redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2]) +redis.call('ZADD', KEYS[2], ARGV[3], ARGV[4]) +return 1 +`; + +const PROMOTE_CLIENT_LUA = ` +redis.call('ZREMRANGEBYSCORE', KEYS[3], '-inf', ARGV[3]) +redis.call('ZREMRANGEBYSCORE', KEYS[4], '-inf', ARGV[3]) +local globalMember = redis.call('ZSCORE', KEYS[3], ARGV[2]) +local userMember = redis.call('ZSCORE', KEYS[4], ARGV[2]) +if not globalMember and redis.call('ZCARD', KEYS[3]) >= tonumber(ARGV[5]) then + return 0 +end +if not userMember and redis.call('ZCARD', KEYS[4]) >= tonumber(ARGV[6]) then + return 0 +end +if redis.call('EXPIRE', KEYS[1], ARGV[1]) == 0 then + return 0 +end +redis.call('ZREM', KEYS[2], ARGV[2]) +redis.call('ZADD', KEYS[3], ARGV[4], ARGV[2]) +redis.call('ZADD', KEYS[4], ARGV[4], ARGV[2]) +redis.call('EXPIRE', KEYS[4], ARGV[1]) +return 1 +`; + +const CREATE_REFRESH_SESSION_LUA = ` +local previousSessionId = redis.call('GET', KEYS[3]) +if previousSessionId then + local previousSessionKey = ARGV[1] .. previousSessionId + local previous = redis.call('GET', previousSessionKey) + if previous then + local decoded = cjson.decode(previous) + redis.call('DEL', ARGV[2] .. decoded.currentTokenHash) + end + redis.call('DEL', previousSessionKey) +end +redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[6]) +redis.call('SET', KEYS[2], ARGV[4], 'EX', ARGV[6]) +redis.call('SET', KEYS[3], ARGV[5], 'EX', ARGV[6]) +return 1 +`; + +const ROTATE_REFRESH_TOKEN_LUA = ` +local marker = redis.call('GET', KEYS[1]) +if marker == ARGV[1] then + local session = redis.call('GET', KEYS[3]) + if session then + local decoded = cjson.decode(session) + redis.call('DEL', ARGV[5] .. decoded.currentTokenHash) + end + redis.call('DEL', KEYS[3]) + return {'reuse'} +end +if marker ~= ARGV[2] then + return {'invalid'} +end +local session = redis.call('GET', KEYS[3]) +if not session or session ~= ARGV[3] then + return {'invalid'} +end +redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL') +redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[6]) +redis.call('SET', KEYS[3], ARGV[4], 'EX', ARGV[6]) +return {'ok'} +`; + +const REVOKE_REFRESH_SESSION_LUA = ` +local marker = redis.call('GET', KEYS[1]) +if marker ~= ARGV[3] then + return 0 +end +local session = redis.call('GET', KEYS[2]) +if not session then + return 0 +end +local decoded = cjson.decode(session) +if decoded.clientId ~= ARGV[1] or decoded.currentTokenHash ~= ARGV[4] then + return 0 +end +redis.call('DEL', ARGV[2] .. decoded.currentTokenHash) +redis.call('DEL', KEYS[1]) +redis.call('DEL', KEYS[2]) +return 1 +`; + +type RemoteMcpOAuthClient = { + clientId: string; + clientName?: string; + redirectUris: string[]; + grantTypes: ('authorization_code' | 'refresh_token')[]; +}; + +type RemoteMcpAuthorizationCode = { + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scopes: string[]; +}; + +type RemoteMcpConsentBinding = { + userId: string; + requestTarget: string; +}; + +type RemoteMcpRefreshSession = { + sessionId: string; + userId: string; + clientId: string; + resource: string; + scopes: string[]; + currentTokenHash: string; + expiresAt: number; +}; + +function clientKey(clientId: string): string { + return `${CLIENT_KEY_PREFIX}${clientId}`; +} + +function codeKey(code: string): string { + return `${CODE_KEY_PREFIX}${code}`; +} + +function consentKey(token: string): string { + return `${CONSENT_KEY_PREFIX}${token}`; +} + +function refreshClientSessionKey(userId: string, clientId: string): string { + const clientHash = createHash('sha256') + .update(`${userId}\0${clientId}`) + .digest('hex'); + return `${REFRESH_CLIENT_SESSION_KEY_PREFIX}${clientHash}`; +} + +function refreshSessionKey(sessionId: string): string { + return `${REFRESH_SESSION_KEY_PREFIX}${sessionId}`; +} + +function refreshTokenHash(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function refreshTokenKey(tokenHash: string): string { + return `${REFRESH_TOKEN_KEY_PREFIX}${tokenHash}`; +} + +function createRefreshToken(sessionId: string): string { + return `${sessionId}.${randomBytes(32).toString('base64url')}`; +} + +function parseRefreshToken(token: string): string | null { + const separator = token.indexOf('.'); + const sessionId = token.slice(0, separator); + const secret = token.slice(separator + 1); + return /^[a-f0-9]{64}$/.test(sessionId) && secret.length >= 32 + ? sessionId + : null; +} + +export function isAllowedOAuthRedirectUri(value: string): boolean { + try { + const url = new URL(value); + return ( + url.hash === '' && + url.username === '' && + url.password === '' && + (url.protocol === 'https:' || + (url.protocol === 'http:' && + (url.hostname === '127.0.0.1' || url.hostname === 'localhost'))) + ); + } catch { + return false; + } +} + +export async function registerRemoteMcpOAuthClient(input: { + clientName?: string; + redirectUris: string[]; + grantTypes?: ('authorization_code' | 'refresh_token')[]; +}): Promise { + const client: RemoteMcpOAuthClient = { + clientId: randomUUID(), + ...(input.clientName ? { clientName: input.clientName } : {}), + redirectUris: input.redirectUris, + grantTypes: input.grantTypes ?? ['authorization_code'], + }; + + const now = Math.floor(Date.now() / 1000); + const stored = await getRedis().eval( + REGISTER_CLIENT_LUA, + 2, + clientKey(client.clientId), + REGISTERED_CLIENTS_KEY, + JSON.stringify(client), + String(PENDING_CLIENT_TTL_SECONDS), + String(now + PENDING_CLIENT_TTL_SECONDS), + client.clientId, + String(MAX_REGISTERED_CLIENTS), + String(now), + ); + if (stored !== 1) { + throw new Error('Remote MCP client registration capacity reached'); + } + return client; +} + +export async function promoteRemoteMcpOAuthClient( + clientId: string, + userId: string, +): Promise { + const now = Math.floor(Date.now() / 1000); + const userHash = createHash('sha256').update(userId).digest('hex'); + const promoted = await getRedis().eval( + PROMOTE_CLIENT_LUA, + 4, + clientKey(clientId), + REGISTERED_CLIENTS_KEY, + ACTIVE_CLIENTS_KEY, + `${ACTIVE_CLIENTS_USER_KEY_PREFIX}${userHash}`, + String(ACTIVE_CLIENT_TTL_SECONDS), + clientId, + String(now), + String(now + ACTIVE_CLIENT_TTL_SECONDS), + String(MAX_ACTIVE_CLIENTS_GLOBAL), + String(MAX_ACTIVE_CLIENTS_PER_USER), + ); + return promoted === 1; +} + +export async function getRemoteMcpOAuthClient( + clientId: string, +): Promise { + const value = await getRedis().get(clientKey(clientId)); + if (!value) return null; + const client = JSON.parse(value) as Omit< + RemoteMcpOAuthClient, + 'grantTypes' + > & { + grantTypes?: RemoteMcpOAuthClient['grantTypes']; + }; + return { + ...client, + grantTypes: client.grantTypes ?? ['authorization_code'], + }; +} + +export async function createRemoteMcpAuthorizationCode( + value: RemoteMcpAuthorizationCode, +): Promise { + const code = randomBytes(32).toString('base64url'); + await getRedis().set( + codeKey(code), + JSON.stringify(value), + 'EX', + AUTHORIZATION_CODE_TTL_SECONDS, + 'NX', + ); + return code; +} + +export async function createRemoteMcpConsentToken( + value: RemoteMcpConsentBinding, +): Promise { + const token = randomBytes(32).toString('base64url'); + await getRedis().set( + consentKey(token), + JSON.stringify(value), + 'EX', + CONSENT_TOKEN_TTL_SECONDS, + 'NX', + ); + return token; +} + +export async function getRemoteMcpAuthorizationCode( + code: string, +): Promise { + const value = await getRedis().get(codeKey(code)); + return value ? (JSON.parse(value) as RemoteMcpAuthorizationCode) : null; +} + +export async function consumeRemoteMcpAuthorizationCode( + code: string, + expected: RemoteMcpAuthorizationCode, +): Promise { + const value = await getRedis().eval( + CONSUME_CODE_LUA, + 1, + codeKey(code), + JSON.stringify(expected), + ); + return typeof value === 'string'; +} + +export async function consumeRemoteMcpConsentToken( + token: string, + expected: RemoteMcpConsentBinding, +): Promise { + const value = await getRedis().eval( + CONSUME_CODE_LUA, + 1, + consentKey(token), + JSON.stringify(expected), + ); + return typeof value === 'string'; +} + +export async function createRemoteMcpRefreshSession(value: { + userId: string; + clientId: string; + resource: string; + scopes: string[]; +}): Promise { + const sessionId = randomBytes(32).toString('hex'); + const refreshToken = createRefreshToken(sessionId); + const tokenHash = refreshTokenHash(refreshToken); + const now = Math.floor(Date.now() / 1000); + const session: RemoteMcpRefreshSession = { + sessionId, + ...value, + currentTokenHash: tokenHash, + expiresAt: now + REFRESH_SESSION_TTL_SECONDS, + }; + await getRedis().eval( + CREATE_REFRESH_SESSION_LUA, + 3, + refreshSessionKey(sessionId), + refreshTokenKey(tokenHash), + refreshClientSessionKey(value.userId, value.clientId), + REFRESH_SESSION_KEY_PREFIX, + REFRESH_TOKEN_KEY_PREFIX, + JSON.stringify(session), + `active:${sessionId}`, + sessionId, + String(REFRESH_SESSION_TTL_SECONDS), + ); + return refreshToken; +} + +export async function getRemoteMcpRefreshSession( + refreshToken: string, +): Promise { + const sessionId = parseRefreshToken(refreshToken); + if (!sessionId) return null; + const tokenHash = refreshTokenHash(refreshToken); + const redis = getRedis(); + const [marker, value] = await Promise.all([ + redis.get(refreshTokenKey(tokenHash)), + redis.get(refreshSessionKey(sessionId)), + ]); + if (marker !== `active:${sessionId}` || !value) return null; + const session = JSON.parse(value) as RemoteMcpRefreshSession; + return session.currentTokenHash === tokenHash ? session : null; +} + +export async function rotateRemoteMcpRefreshToken( + refreshToken: string, + expected: RemoteMcpRefreshSession, +): Promise< + { status: 'ok'; refreshToken: string } | { status: 'invalid' | 'reuse' } +> { + const sessionId = parseRefreshToken(refreshToken); + if (!sessionId || sessionId !== expected.sessionId) { + return { status: 'invalid' }; + } + const now = Math.floor(Date.now() / 1000); + const ttl = expected.expiresAt - now; + if (ttl <= 0) return { status: 'invalid' }; + + const nextRefreshToken = createRefreshToken(sessionId); + const nextTokenHash = refreshTokenHash(nextRefreshToken); + const nextSession = { ...expected, currentTokenHash: nextTokenHash }; + const oldTokenHash = refreshTokenHash(refreshToken); + const result = (await getRedis().eval( + ROTATE_REFRESH_TOKEN_LUA, + 3, + refreshTokenKey(oldTokenHash), + refreshTokenKey(nextTokenHash), + refreshSessionKey(sessionId), + `rotated:${sessionId}`, + `active:${sessionId}`, + JSON.stringify(expected), + JSON.stringify(nextSession), + REFRESH_TOKEN_KEY_PREFIX, + String(ttl), + )) as string[]; + if (result[0] !== 'ok') { + return { status: result[0] === 'reuse' ? 'reuse' : 'invalid' }; + } + return { status: 'ok', refreshToken: nextRefreshToken }; +} + +export async function revokeRemoteMcpRefreshSession( + refreshToken: string, + clientId: string, +): Promise { + const sessionId = parseRefreshToken(refreshToken); + if (!sessionId) return; + const tokenHash = refreshTokenHash(refreshToken); + await getRedis().eval( + REVOKE_REFRESH_SESSION_LUA, + 2, + refreshTokenKey(tokenHash), + refreshSessionKey(sessionId), + clientId, + REFRESH_TOKEN_KEY_PREFIX, + `active:${sessionId}`, + tokenHash, + ); +} + +export async function isRemoteMcpRegistrationAllowed( + registrationFingerprint: string, +): Promise { + const window = Math.floor( + Date.now() / (REGISTRATION_RATE_LIMIT_WINDOW_SECONDS * 1000), + ); + const clientHash = createHash('sha256') + .update(registrationFingerprint) + .digest('hex'); + const admitted = await getRedis().eval( + ADMIT_REGISTRATION_LUA, + 2, + `${REGISTRATION_RATE_KEY_PREFIX}client:${clientHash}:${window}`, + `${REGISTRATION_RATE_KEY_PREFIX}global:${window}`, + String(REGISTRATION_RATE_LIMIT_WINDOW_SECONDS), + String(REGISTRATION_RATE_LIMIT_PER_CLIENT), + String(REGISTRATION_RATE_LIMIT_GLOBAL), + ); + return admitted === 1; +} + +export function verifyPkceChallenge( + verifier: string, + expectedChallenge: string, +): boolean { + return ( + createHash('sha256').update(verifier).digest('base64url') === + expectedChallenge + ); +} diff --git a/apps/web/src/lib/server/remote-mcp-proxy.test.ts b/apps/web/src/lib/server/remote-mcp-proxy.test.ts new file mode 100644 index 000000000..d25829e0e --- /dev/null +++ b/apps/web/src/lib/server/remote-mcp-proxy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { sanitizeProxiedResponseHeaders } from './remote-mcp-proxy'; + +describe('sanitizeProxiedResponseHeaders', () => { + it('removes stale body encoding and transport headers', () => { + const headers = sanitizeProxiedResponseHeaders( + new Headers({ + connection: 'keep-alive', + 'content-encoding': 'gzip', + 'content-length': '123', + 'content-type': 'application/json', + 'mcp-session-id': 'session-1', + }), + ); + + expect(Object.fromEntries(headers)).toEqual({ + 'content-type': 'application/json', + 'mcp-session-id': 'session-1', + }); + }); +}); diff --git a/apps/web/src/lib/server/remote-mcp-proxy.ts b/apps/web/src/lib/server/remote-mcp-proxy.ts new file mode 100644 index 000000000..8ed1a7626 --- /dev/null +++ b/apps/web/src/lib/server/remote-mcp-proxy.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + getRoomoteMcpProtectedResourceMetadataUrl, + getRoomoteMcpResourceUrl, +} from '@roomote/auth'; + +import { bootstrapWebRuntimeEnv } from './bootstrap-runtime-env'; + +const HOP_BY_HOP_HEADERS = [ + 'connection', + 'content-length', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]; + +function removeHopByHopHeaders(headers: Headers): Headers { + const nextHeaders = new Headers(headers); + for (const header of HOP_BY_HOP_HEADERS) nextHeaders.delete(header); + return nextHeaders; +} + +function sanitizeRequestHeaders(headers: Headers): Headers { + const nextHeaders = removeHopByHopHeaders(headers); + nextHeaders.delete('host'); + nextHeaders.delete('forwarded'); + for (const header of [...nextHeaders.keys()]) { + if (header.startsWith('x-forwarded-')) nextHeaders.delete(header); + } + return nextHeaders; +} + +export function sanitizeProxiedResponseHeaders(headers: Headers): Headers { + const nextHeaders = removeHopByHopHeaders(headers); + + // fetch() transparently decompresses upstream responses while retaining the + // original content-encoding header. Forwarding that stale header makes the + // client try to decompress an already-decoded response body. + nextHeaders.delete('content-encoding'); + + return nextHeaders; +} + +export async function proxyRemoteMcpRequest( + request: NextRequest, + endpoint: 'mcp' | 'metadata', +) { + const env = await bootstrapWebRuntimeEnv(); + const targetUrl = + endpoint === 'mcp' + ? new URL(getRoomoteMcpResourceUrl(env.TRPC_URL)) + : new URL(getRoomoteMcpProtectedResourceMetadataUrl(env.TRPC_URL)); + targetUrl.search = request.nextUrl.search; + + const publicUrl = new URL(env.R_PUBLIC_URL ?? env.R_APP_URL); + const headers = sanitizeRequestHeaders(request.headers); + headers.set('x-forwarded-host', publicUrl.host); + headers.set('x-forwarded-proto', publicUrl.protocol.replace(':', '')); + + const response = await fetch(targetUrl, { + method: request.method, + headers, + body: + request.method === 'GET' || request.method === 'HEAD' + ? undefined + : await request.arrayBuffer(), + redirect: 'manual', + }); + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: sanitizeProxiedResponseHeaders(response.headers), + }); +} diff --git a/packages/auth/src/__tests__/mcp-access-token.test.ts b/packages/auth/src/__tests__/mcp-access-token.test.ts new file mode 100644 index 000000000..d390bc059 --- /dev/null +++ b/packages/auth/src/__tests__/mcp-access-token.test.ts @@ -0,0 +1,101 @@ +import { generateKeyPairSync } from 'node:crypto'; + +const testKeyPair = generateKeyPairSync('ec', { + namedCurve: 'P-256', + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +const { mockJwtSign, mockJwtVerify } = vi.hoisted(() => ({ + mockJwtSign: vi.fn(), + mockJwtVerify: vi.fn(), +})); + +vi.mock('jsonwebtoken', () => ({ + default: { + sign: (...args: unknown[]) => mockJwtSign(...args), + verify: (...args: unknown[]) => mockJwtVerify(...args), + }, +})); + +vi.mock('../client-runtime', () => ({ + getJobAuthPrivateKey: () => + Buffer.from(testKeyPair.privateKey).toString('base64'), + getJobAuthPublicKey: () => + Buffer.from(testKeyPair.publicKey).toString('base64'), + isAuthClientTestEnv: () => false, +})); + +import { + createMcpAccessToken, + getLegacyRoomoteMcpResourceUrl, + getRoomoteMcpProtectedResourceMetadataUrl, + getRoomoteMcpResourceUrl, + ROOMOTE_MCP_SCOPE, + validateMcpAccessToken, +} from '../mcp-access-token'; + +const resource = 'https://api.example.com/mcp'; + +describe('MCP access tokens', () => { + beforeEach(() => vi.clearAllMocks()); + + it('mints a resource- and scope-bound token', async () => { + mockJwtSign.mockReturnValue('signed-token'); + + await expect( + createMcpAccessToken({ + userId: 'user-1', + resource, + scopes: [ROOMOTE_MCP_SCOPE], + timeoutMs: 60_000, + }), + ).resolves.toBe('signed-token'); + + expect(mockJwtSign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: 'user-1', + aud: resource, + r: { u: 'user-1', t: 'mcp', s: [ROOMOTE_MCP_SCOPE] }, + }), + testKeyPair.privateKey, + { algorithm: 'ES256' }, + ); + }); + + it('returns the resource boundary when validating', async () => { + const now = Math.floor(Date.now() / 1000); + mockJwtVerify.mockReturnValue({ + iss: 'rcc', + sub: 'user-1', + aud: resource, + exp: now + 60, + iat: now, + nbf: now - 1, + v: 1, + r: { u: 'user-1', t: 'mcp', s: [ROOMOTE_MCP_SCOPE] }, + }); + + await expect(validateMcpAccessToken('token')).resolves.toEqual({ + userId: 'user-1', + tokenType: 'mcp', + version: 1, + resource, + scopes: [ROOMOTE_MCP_SCOPE], + }); + }); + + it('preserves a single-origin API proxy prefix in MCP URLs', () => { + const apiBaseUrl = 'https://roomote.example/_roomote-api'; + + expect(getRoomoteMcpResourceUrl(apiBaseUrl)).toBe( + 'https://roomote.example/_roomote-api/mcp', + ); + expect(getLegacyRoomoteMcpResourceUrl(apiBaseUrl)).toBe( + 'https://roomote.example/_roomote-api/api/mcp-routing/roomote', + ); + expect(getRoomoteMcpProtectedResourceMetadataUrl(apiBaseUrl)).toBe( + 'https://roomote.example/_roomote-api/.well-known/oauth-protected-resource/mcp', + ); + }); +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 6233f8036..d84a85900 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -21,6 +21,20 @@ export { validateAuthToken, } from './auth-token'; +export { + type CreateMcpAccessTokenOptions, + DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + ROOMOTE_MCP_LEGACY_PATH, + ROOMOTE_MCP_PATH, + ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, + ROOMOTE_MCP_SCOPE, + createMcpAccessToken, + getLegacyRoomoteMcpResourceUrl, + getRoomoteMcpProtectedResourceMetadataUrl, + getRoomoteMcpResourceUrl, + validateMcpAccessToken, +} from './mcp-access-token'; + export { type GitHubAppCredentials, type CreateGitHubTokenOptions, diff --git a/packages/auth/src/mcp-access-token.ts b/packages/auth/src/mcp-access-token.ts new file mode 100644 index 000000000..a6df0a718 --- /dev/null +++ b/packages/auth/src/mcp-access-token.ts @@ -0,0 +1,120 @@ +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; + +import { + type McpAccessTokenContext, + type McpAccessTokenPayload, + mcpAccessTokenPayloadSchema, +} from '@roomote/types'; + +import { + getJobAuthPrivateKey, + getJobAuthPublicKey, + isAuthClientTestEnv, +} from './client-runtime'; +import { + decodeEs256PrivateKeyPem, + decodeEs256PublicKeyPem, +} from './decode-es256-key'; + +const ISSUER = 'rcc'; +export const ROOMOTE_MCP_SCOPE = 'mcp:roomote'; +export const ROOMOTE_MCP_PATH = '/mcp'; +export const ROOMOTE_MCP_LEGACY_PATH = '/api/mcp-routing/roomote'; +export const ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = + '/.well-known/oauth-protected-resource/mcp'; +export const DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS = 60 * 60 * 1000; + +function appendPathToBaseUrl(baseUrl: string, path: string): string { + const url = new URL(baseUrl); + url.pathname = `${url.pathname.replace(/\/$/, '')}${path}`; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +export function getRoomoteMcpResourceUrl(apiBaseUrl: string): string { + return appendPathToBaseUrl(apiBaseUrl, ROOMOTE_MCP_PATH); +} + +export function getLegacyRoomoteMcpResourceUrl(apiBaseUrl: string): string { + return appendPathToBaseUrl(apiBaseUrl, ROOMOTE_MCP_LEGACY_PATH); +} + +export function getRoomoteMcpProtectedResourceMetadataUrl( + apiBaseUrl: string, +): string { + return appendPathToBaseUrl( + apiBaseUrl, + ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, + ); +} + +const createMcpAccessTokenOptionsSchema = z.object({ + userId: z.string().min(1), + resource: z.string().url(), + scopes: z.array(z.literal(ROOMOTE_MCP_SCOPE)).min(1), + timeoutMs: z + .number() + .int() + .positive() + .max(DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS), +}); + +export type CreateMcpAccessTokenOptions = z.infer< + typeof createMcpAccessTokenOptionsSchema +>; + +export async function createMcpAccessToken( + options: CreateMcpAccessTokenOptions, +): Promise { + const { userId, resource, scopes, timeoutMs } = + createMcpAccessTokenOptionsSchema.parse(options); + const now = Math.floor(Date.now() / 1000); + + const payload: McpAccessTokenPayload = { + iss: ISSUER, + sub: userId, + aud: resource, + exp: now + Math.floor(timeoutMs / 1000), + iat: now, + nbf: now - 30, + v: 1, + r: { + u: userId, + t: 'mcp', + s: scopes, + }, + }; + + const privateKey = decodeEs256PrivateKeyPem( + getJobAuthPrivateKey(), + 'JOB_AUTH_PRIVATE_KEY', + ); + + return jwt.sign(payload, privateKey, { algorithm: 'ES256' }); +} + +export async function validateMcpAccessToken( + token: string, +): Promise { + const publicKey = decodeEs256PublicKeyPem( + getJobAuthPublicKey(), + 'JOB_AUTH_PUBLIC_KEY', + ); + const rawPayload = jwt.verify(token, publicKey, { + algorithms: ['ES256'], + clockTolerance: 60, + ignoreNotBefore: isAuthClientTestEnv(), + issuer: ISSUER, + }); + const payload = mcpAccessTokenPayloadSchema.parse(rawPayload); + + return { + userId: payload.r.u, + tokenType: 'mcp', + version: payload.v, + resource: payload.aud, + scopes: payload.r.s, + }; +} diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index 8c7aa97d9..5dca242ce 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -75,9 +75,41 @@ export interface UserAuthTokenContext { export type AuthTokenContext = UserAuthTokenContext; export const isUserToken = ( - token: AuthTokenContext | undefined, -): token is UserAuthTokenContext => - typeof token === 'object' && 'userId' in token; + token: { tokenType: string } | undefined, +): token is UserAuthTokenContext => token?.tokenType === 'auth'; + +/** + * Browser-issued OAuth token for the public Roomote MCP resource. + * + * This is deliberately distinct from the internal user auth token so an MCP + * client cannot use its bearer credential against the rest of the API. + */ +export const mcpAccessTokenPayloadSchema = z.object({ + iss: z.string().min(1, 'Issuer (iss) is required'), + sub: z.string().min(1, 'Subject (sub) is required'), + aud: z.string().url('Audience (aud) must be a URL'), + exp: z.number().int().positive('Expiration (exp) must be a positive integer'), + iat: z.number().int().positive('Issued at (iat) must be a positive integer'), + nbf: z.number().int().positive('Not before (nbf) must be a positive integer'), + v: z.literal(1, { errorMap: () => ({ message: 'Version must be 1' }) }), + r: z.object({ + u: z.string().min(1, 'User ID is required'), + t: z.literal('mcp', { + errorMap: () => ({ message: 'Token type must be "mcp"' }), + }), + s: z.array(z.string().min(1)).min(1, 'At least one scope is required'), + }), +}); + +export type McpAccessTokenPayload = z.infer; + +export interface McpAccessTokenContext { + userId: string; + tokenType: 'mcp'; + version: number; + resource: string; + scopes: string[]; +} /** * PreviewTokenPayload