From efa6f387cfd88a0037ef1deeced802142fbacde9 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:11:12 +0000 Subject: [PATCH 01/15] feat: add OAuth for remote Roomote MCP --- .../route-policy-enforcement.test.ts | 78 ++++++++++++ apps/api/src/handlers/index.ts | 1 + apps/api/src/handlers/mcp-oauth.ts | 23 ++++ apps/api/src/handlers/mcp/roomote.ts | 12 ++ .../__tests__/tokenAuthMiddleware.test.ts | 37 +++++- .../src/middleware/routePolicyMiddleware.ts | 29 ++++- .../api/src/middleware/tokenAuthMiddleware.ts | 42 +++++-- apps/api/src/route-policies.ts | 15 +++ apps/api/src/server.ts | 2 + apps/api/src/types.ts | 12 +- apps/docs/docs.json | 1 + apps/docs/integrations/index.mdx | 5 + apps/docs/integrations/remote-roomote-mcp.mdx | 64 ++++++++++ .../oauth-authorization-server/route.ts | 29 +++++ .../authorize/__tests__/route.test.ts | 84 +++++++++++++ .../api/mcp-remote-oauth/authorize/route.ts | 87 +++++++++++++ .../register/__tests__/route.test.ts | 59 +++++++++ .../api/mcp-remote-oauth/register/route.ts | 58 +++++++++ .../token/__tests__/route.test.ts | 114 ++++++++++++++++++ .../app/api/mcp-remote-oauth/token/route.ts | 76 ++++++++++++ .../src/lib/server/mcp-remote-oauth.test.ts | 78 ++++++++++++ apps/web/src/lib/server/mcp-remote-oauth.ts | 104 ++++++++++++++++ .../src/__tests__/mcp-access-token.test.ts | 84 +++++++++++++ packages/auth/src/index.ts | 10 ++ packages/auth/src/mcp-access-token.ts | 96 +++++++++++++++ packages/types/src/auth.ts | 38 +++++- 26 files changed, 1216 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/handlers/mcp-oauth.ts create mode 100644 apps/docs/integrations/remote-roomote-mcp.mdx create mode 100644 apps/web/src/app/.well-known/oauth-authorization-server/route.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/authorize/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/register/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/register/route.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/token/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/token/route.ts create mode 100644 apps/web/src/lib/server/mcp-remote-oauth.test.ts create mode 100644 apps/web/src/lib/server/mcp-remote-oauth.ts create mode 100644 packages/auth/src/__tests__/mcp-access-token.test.ts create mode 100644 packages/auth/src/mcp-access-token.ts diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index 85c1490c4..062f6e71d 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -75,6 +75,16 @@ vi.mock('../middleware', async (importOriginal) => { } as Variables['authContext']); } + if (authHeader === 'Bearer test-mcp-token') { + c.set('authContext', { + tokenType: 'mcp', + userId: 'user-123', + resource: 'http://localhost/api/mcp-routing/roomote', + scopes: ['mcp:roomote'], + version: 1, + } as Variables['authContext']); + } + await next(); }, }; @@ -113,6 +123,21 @@ 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/api/mcp-routing/roomote', + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + 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( @@ -171,11 +196,42 @@ describe('route policy enforcement', () => { ); expect(mcpRoutingResponse.status).toBe(401); + expect(mcpRoutingResponse.headers.get('www-authenticate')).toBe( + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp-routing/roomote"', + ); 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/api/mcp-routing/roomote', + { + method: 'POST', + headers: { authorization: 'Bearer test-mcp-token' }, + body: '{}', + }, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + error: { message: expect.stringContaining('requires a user-scoped') }, + }); + }); + 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 +453,14 @@ describe('route policy enforcement', () => { version: 1, } as Variables['authContext']; + const mcpToken = { + tokenType: 'mcp', + userId: 'user-123', + resource: 'https://api.example.com/api/mcp-routing/roomote', + 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 +488,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..4be083395 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -22,6 +22,7 @@ export { trpc } from './trpc'; // mcp export { mcp } from './mcp'; export { mcpRouting } from './mcp/routing'; +export { mcpOAuthMetadata } from './mcp-oauth'; // 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..2897c8b5d --- /dev/null +++ b/apps/api/src/handlers/mcp-oauth.ts @@ -0,0 +1,23 @@ +import { Hono } from 'hono'; + +import { getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE } from '@roomote/auth'; +import { Env } from '@roomote/env'; + +import type { Variables } from '../types'; + +export const ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = + '/.well-known/oauth-protected-resource/api/mcp-routing/roomote'; + +export const mcpOAuthMetadata = new Hono<{ Variables: Variables }>(); + +mcpOAuthMetadata.get(ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, (c) => { + const authorizationServer = Env.R_PUBLIC_URL ?? Env.R_APP_URL; + + c.header('Cache-Control', 'public, max-age=3600'); + return c.json({ + resource: getRoomoteMcpResourceUrl(Env.TRPC_URL), + authorization_servers: [new URL(authorizationServer).origin], + bearer_methods_supported: ['header'], + scopes_supported: [ROOMOTE_MCP_SCOPE], + }); +}); diff --git a/apps/api/src/handlers/mcp/roomote.ts b/apps/api/src/handlers/mcp/roomote.ts index a779d69a2..3857631ca 100644 --- a/apps/api/src/handlers/mcp/roomote.ts +++ b/apps/api/src/handlers/mcp/roomote.ts @@ -109,6 +109,18 @@ async function resolveRoomoteMcpAuth( }; } + if ( + authContext.tokenType === 'mcp' && + authContext.resource === + new URL('/api/mcp-routing/roomote', Env.TRPC_URL).toString() && + authContext.scopes.includes('mcp:roomote') + ) { + return { + userId: authContext.userId, + tokenType: 'auth', + }; + } + throw new McpProxyError( 403, `${PRODUCT_NAME} MCP requires a user-scoped auth token or task run token`, diff --git a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts index 6ae22455d..4cde6aea3 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/api/mcp-routing/roomote', + scopes: ['mcp:roomote'], + version: 1, + }; + mockValidateMcpAccessToken.mockResolvedValue(mcpContext); + + const authContext = await requestAuthContext('/api/mcp-routing/roomote', { + 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..75f23bf3a 100644 --- a/apps/api/src/middleware/routePolicyMiddleware.ts +++ b/apps/api/src/middleware/routePolicyMiddleware.ts @@ -3,7 +3,7 @@ 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 type { Variables } from '../types'; @@ -40,6 +40,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 +67,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 +83,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 +103,17 @@ function rejectionResponse( rule: RoutePolicyRule, rejection: RoutePolicyRejection, ): Response { + if (rule.name === 'roomote-mcp' && rejection.status === 401) { + const resourceMetadata = new URL( + '/.well-known/oauth-protected-resource/api/mcp-routing/roomote', + c.req.url, + ); + c.header( + 'WWW-Authenticate', + `Bearer resource_metadata="${resourceMetadata.toString()}"`, + ); + } + 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..7a48c0ea9 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,14 @@ 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', + }, // Sandbox OIDC discovery documents consumed by external verifiers. { @@ -238,6 +247,12 @@ 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-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..84d12cfb8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -49,6 +49,7 @@ import { inference, mcp, mcpRouting, + mcpOAuthMetadata, taskRunsRouter, artifactsRouter, taskArtifactsRouter, @@ -211,6 +212,7 @@ export function createApiApp(): ApiApp { app.route('/api/inference', inference); app.route('/api/mcp', mcp); app.route('/api/mcp-routing', mcpRouting); + 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..c1aa967df 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -135,6 +135,7 @@ "pages": [ "integrations/index", "integrations/custom-mcp-servers", + "integrations/remote-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..1d494a76a 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -89,6 +89,11 @@ 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 +[Remote Roomote MCP](/integrations/remote-roomote-mcp). Its browser-issued +credential is limited to Roomote's shared context tools and does not grant +general API access. + ## A practical order For most teams, this order works well: diff --git a/apps/docs/integrations/remote-roomote-mcp.mdx b/apps/docs/integrations/remote-roomote-mcp.mdx new file mode 100644 index 000000000..f56e31cb3 --- /dev/null +++ b/apps/docs/integrations/remote-roomote-mcp.mdx @@ -0,0 +1,64 @@ +--- +title: Remote Roomote MCP +icon: plug-circle-bolt +description: Connect an OAuth-capable MCP client to Roomote's shared context tools. +--- + +Roomote exposes its context and environment-discovery tools as a remote MCP +server. OAuth-capable clients can connect without manually creating or copying +an API token. + +## Prerequisites + +Before connecting a client: + +- `TRPC_URL` must be a browser-reachable HTTPS API origin +- `R_PUBLIC_URL`, or `R_APP_URL` when no public URL is set, must be a + browser-reachable HTTPS web origin +- both origins must be able to reach the same Redis deployment and use 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 +/api/mcp-routing/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. The client must support OAuth authorization code +flow with S256 PKCE and dynamic client registration. + +Roomote currently issues a one-hour access token without a refresh token. When +it expires, the client starts the browser authorization flow again. + +## 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 +- dynamically registered clients expire after 30 days and can register only + HTTPS or loopback HTTP callback URLs + +This endpoint exposes the same Roomote context tools used for routing before a +task starts. It does not grant a client a task-run identity or access to tools +that require an active Roomote task. + +## 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/route.ts b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 000000000..83fec3efc --- /dev/null +++ b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,29 @@ +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`, + registration_endpoint: `${issuer}/api/mcp-remote-oauth/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + 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/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..ff30da1d2 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/__tests__/route.test.ts @@ -0,0 +1,84 @@ +import { NextRequest } from 'next/server'; + +const { mockAuthorize, mockGetClient, mockCreateCode } = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockGetClient: vi.fn(), + mockCreateCode: 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, +})); + +import { GET } from '../route'; + +const clientId = '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568'; +const redirectUri = 'https://client.example/callback'; + +function authorizeRequest() { + 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://api.example.com/api/mcp-routing/roomote', + ); + url.searchParams.set('scope', 'mcp:roomote'); + return new NextRequest(url); +} + +describe('GET /api/mcp-remote-oauth/authorize', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetClient.mockResolvedValue({ + clientId, + redirectUris: [redirectUri], + }); + }); + + 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('issues a resource-bound code for the signed-in user', async () => { + mockAuthorize.mockResolvedValue({ success: true, userId: 'user-1' }); + mockCreateCode.mockResolvedValue('authorization-code'); + + const response = await GET(authorizeRequest()); + const location = new URL(response.headers.get('location')!); + + 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://api.example.com/api/mcp-routing/roomote', + scopes: ['mcp:roomote'], + }); + }); +}); 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..136b326b0 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -0,0 +1,87 @@ +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, + 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(), +}); + +export async function GET(request: NextRequest) { + 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.TRPC_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 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); +} 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..2437b6ba2 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/register/__tests__/route.test.ts @@ -0,0 +1,59 @@ +import { NextRequest } from 'next/server'; + +const mockRegisterClient = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ + ...(await importOriginal()), + registerRemoteMcpOAuthClient: mockRegisterClient, +})); + +import { POST } from '../route'; + +function registrationRequest(redirectUri: string) { + 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', + }), + }, + ); +} + +describe('POST /api/mcp-remote-oauth/register', () => { + beforeEach(() => vi.clearAllMocks()); + + 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'], + }); + + 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', + }); + }); + + 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(); + }); +}); 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..2bb92ef85 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { + isAllowedOAuthRedirectUri, + 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()) + .min(1) + .max(10) + .refine((values) => values.every(isAllowedOAuthRedirectUri)), + token_endpoint_auth_method: z.literal('none').optional(), + grant_types: z.array(z.literal('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 client = await registerRemoteMcpOAuthClient({ + clientName: parsed.data.client_name, + redirectUris: parsed.data.redirect_uris, + }); + + return NextResponse.json( + { + client_id: client.clientId, + client_name: client.clientName, + redirect_uris: client.redirectUris, + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code'], + response_types: ['code'], + }, + { status: 201, 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..811ff1707 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/token/__tests__/route.test.ts @@ -0,0 +1,114 @@ +import { createHash } from 'node:crypto'; +import { NextRequest } from 'next/server'; + +const { mockConsumeCode, mockCreateToken } = vi.hoisted(() => ({ + mockConsumeCode: vi.fn(), + mockCreateToken: vi.fn(), +})); + +vi.mock('@roomote/auth', async (importOriginal) => ({ + ...(await importOriginal()), + createMcpAccessToken: mockCreateToken, +})); + +vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ + ...(await importOriginal()), + consumeRemoteMcpAuthorizationCode: mockConsumeCode, +})); + +import { POST } from '../route'; + +const verifier = + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; +const challenge = createHash('sha256').update(verifier).digest('base64url'); +const resource = 'https://api.example.com/api/mcp-routing/roomote'; + +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, + }); +} + +describe('POST /api/mcp-remote-oauth/token', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConsumeCode.mockResolvedValue({ + userId: 'user-1', + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + redirectUri: 'https://client.example/callback', + codeChallenge: challenge, + resource, + scopes: ['mcp:roomote'], + }); + mockCreateToken.mockResolvedValue('access-token'); + }); + + 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', + token_type: 'Bearer', + expires_in: 3600, + scope: 'mcp:roomote', + }); + expect(mockCreateToken).toHaveBeenCalledWith({ + userId: 'user-1', + resource, + scopes: ['mcp:roomote'], + timeoutMs: 3_600_000, + }); + }); + + 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(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(mockConsumeCode).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(mockConsumeCode).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..7e7481f07 --- /dev/null +++ b/apps/web/src/app/api/mcp-remote-oauth/token/route.ts @@ -0,0 +1,76 @@ +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, + verifyPkceChallenge, +} from '@/lib/server/mcp-remote-oauth'; + +export const runtime = 'nodejs'; + +const tokenSchema = 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(), +}); + +function oauthError(error: string) { + return NextResponse.json( + { error }, + { status: 400, 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; + const authorization = await consumeRemoteMcpAuthorizationCode(input.code); + if ( + !authorization || + 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'); + } + + const accessToken = await createMcpAccessToken({ + userId: authorization.userId, + resource: authorization.resource, + scopes: [ROOMOTE_MCP_SCOPE], + timeoutMs: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + }); + + return NextResponse.json( + { + access_token: accessToken, + token_type: 'Bearer', + expires_in: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS / 1000, + scope: ROOMOTE_MCP_SCOPE, + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); +} 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..835f25b57 --- /dev/null +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -0,0 +1,78 @@ +const redisState = 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, + getdel: async (key: string) => { + const value = redisState.get(key) ?? null; + redisState.delete(key); + return value; + }, + }), +})); + +import { + consumeRemoteMcpAuthorizationCode, + createRemoteMcpAuthorizationCode, + isAllowedOAuthRedirectUri, + registerRemoteMcpOAuthClient, + verifyPkceChallenge, +} from './mcp-remote-oauth'; + +describe('remote MCP OAuth state', () => { + beforeEach(() => redisState.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'], + }); + }); + + 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/api/mcp-routing/roomote', + scopes: ['mcp:roomote'], + }; + const code = await createRemoteMcpAuthorizationCode(value); + + await expect(consumeRemoteMcpAuthorizationCode(code)).resolves.toEqual( + value, + ); + await expect(consumeRemoteMcpAuthorizationCode(code)).resolves.toBeNull(); + }); + + 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..235a7869f --- /dev/null +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -0,0 +1,104 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +import { getRedis } from '@roomote/redis'; + +const CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; +const AUTHORIZATION_CODE_TTL_SECONDS = 5 * 60; +const CLIENT_KEY_PREFIX = 'mcp-remote-oauth:client:'; +const CODE_KEY_PREFIX = 'mcp-remote-oauth:code:'; + +export type RemoteMcpOAuthClient = { + clientId: string; + clientName?: string; + redirectUris: string[]; +}; + +export type RemoteMcpAuthorizationCode = { + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scopes: string[]; +}; + +function clientKey(clientId: string): string { + return `${CLIENT_KEY_PREFIX}${clientId}`; +} + +function codeKey(code: string): string { + return `${CODE_KEY_PREFIX}${code}`; +} + +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[]; +}): Promise { + const client: RemoteMcpOAuthClient = { + clientId: randomUUID(), + ...(input.clientName ? { clientName: input.clientName } : {}), + redirectUris: input.redirectUris, + }; + + await getRedis().set( + clientKey(client.clientId), + JSON.stringify(client), + 'EX', + CLIENT_TTL_SECONDS, + ); + return client; +} + +export async function getRemoteMcpOAuthClient( + clientId: string, +): Promise { + const value = await getRedis().get(clientKey(clientId)); + return value ? (JSON.parse(value) as RemoteMcpOAuthClient) : null; +} + +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 consumeRemoteMcpAuthorizationCode( + code: string, +): Promise { + const value = await getRedis().getdel(codeKey(code)); + return value ? (JSON.parse(value) as RemoteMcpAuthorizationCode) : null; +} + +export function verifyPkceChallenge( + verifier: string, + expectedChallenge: string, +): boolean { + return ( + createHash('sha256').update(verifier).digest('base64url') === + expectedChallenge + ); +} 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..52eac8c88 --- /dev/null +++ b/packages/auth/src/__tests__/mcp-access-token.test.ts @@ -0,0 +1,84 @@ +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, + ROOMOTE_MCP_SCOPE, + validateMcpAccessToken, +} from '../mcp-access-token'; + +const resource = 'https://api.example.com/api/mcp-routing/roomote'; + +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], + }); + }); +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 6233f8036..92dcec649 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -21,6 +21,16 @@ export { validateAuthToken, } from './auth-token'; +export { + type CreateMcpAccessTokenOptions, + DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + ROOMOTE_MCP_PATH, + ROOMOTE_MCP_SCOPE, + createMcpAccessToken, + 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..0a4df3053 --- /dev/null +++ b/packages/auth/src/mcp-access-token.ts @@ -0,0 +1,96 @@ +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 = '/api/mcp-routing/roomote'; +export const DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS = 60 * 60 * 1000; + +export function getRoomoteMcpResourceUrl(apiBaseUrl: string): string { + return new URL(ROOMOTE_MCP_PATH, apiBaseUrl).toString(); +} + +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 From ca34566ef382725b5c5e33bf45167a8393480ea8 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:12:00 +0000 Subject: [PATCH 02/15] refactor: narrow remote MCP OAuth exports --- apps/api/src/handlers/mcp-oauth.ts | 2 +- apps/web/src/lib/server/mcp-remote-oauth.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/handlers/mcp-oauth.ts b/apps/api/src/handlers/mcp-oauth.ts index 2897c8b5d..2d08aeb50 100644 --- a/apps/api/src/handlers/mcp-oauth.ts +++ b/apps/api/src/handlers/mcp-oauth.ts @@ -5,7 +5,7 @@ import { Env } from '@roomote/env'; import type { Variables } from '../types'; -export const ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = +const ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource/api/mcp-routing/roomote'; export const mcpOAuthMetadata = new Hono<{ Variables: Variables }>(); diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index 235a7869f..54637dce5 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -7,13 +7,13 @@ const AUTHORIZATION_CODE_TTL_SECONDS = 5 * 60; const CLIENT_KEY_PREFIX = 'mcp-remote-oauth:client:'; const CODE_KEY_PREFIX = 'mcp-remote-oauth:code:'; -export type RemoteMcpOAuthClient = { +type RemoteMcpOAuthClient = { clientId: string; clientName?: string; redirectUris: string[]; }; -export type RemoteMcpAuthorizationCode = { +type RemoteMcpAuthorizationCode = { userId: string; clientId: string; redirectUri: string; From b9cdd44ddcd7083f68a5e09abfb82718d7d449e8 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:49:09 +0000 Subject: [PATCH 03/15] fix: secure and broaden remote MCP access --- .../route-policy-enforcement.test.ts | 132 +++++++++- apps/api/src/handlers/index.ts | 1 + apps/api/src/handlers/mcp-oauth.ts | 19 +- .../src/handlers/mcp/roomote-member-tools.ts | 229 ++++++++++++++++++ apps/api/src/handlers/mcp/roomote.ts | 122 +++++++--- .../__tests__/tokenAuthMiddleware.test.ts | 4 +- .../src/middleware/routePolicyMiddleware.ts | 7 +- apps/api/src/route-policies.ts | 14 ++ apps/api/src/server.ts | 2 + apps/docs/integrations/remote-roomote-mcp.mdx | 30 ++- .../authorize/__tests__/route.test.ts | 27 ++- .../api/mcp-remote-oauth/authorize/route.ts | 78 +++++- .../register/__tests__/route.test.ts | 26 +- .../api/mcp-remote-oauth/register/route.ts | 20 ++ .../token/__tests__/route.test.ts | 18 +- .../app/api/mcp-remote-oauth/token/route.ts | 7 +- .../src/lib/server/mcp-remote-oauth.test.ts | 63 ++++- apps/web/src/lib/server/mcp-remote-oauth.ts | 71 +++++- .../src/__tests__/mcp-access-token.test.ts | 2 +- packages/auth/src/index.ts | 2 + packages/auth/src/mcp-access-token.ts | 7 +- 21 files changed, 785 insertions(+), 96 deletions(-) create mode 100644 apps/api/src/handlers/mcp/roomote-member-tools.ts diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index 062f6e71d..c7b394e31 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -1,4 +1,6 @@ import type { Context, Next } from 'hono'; +import { getRoomoteMcpResourceUrl } from '@roomote/auth'; +import { Env } from '@roomote/env'; import type { Variables } from '../types'; @@ -79,7 +81,30 @@ vi.mock('../middleware', async (importOriginal) => { c.set('authContext', { tokenType: 'mcp', userId: 'user-123', - resource: 'http://localhost/api/mcp-routing/roomote', + resource: getRoomoteMcpResourceUrl(Env.TRPC_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: new URL( + '/api/mcp-routing/roomote', + Env.TRPC_URL, + ).toString(), scopes: ['mcp:roomote'], version: 1, } as Variables['authContext']); @@ -126,11 +151,12 @@ 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/api/mcp-routing/roomote', + 'http://localhost/.well-known/oauth-protected-resource/mcp', ); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ + resource: expect.stringMatching(/\/mcp$/), authorization_servers: [expect.any(String)], bearer_methods_supported: ['header'], scopes_supported: ['mcp:roomote'], @@ -191,13 +217,13 @@ 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="http://localhost/.well-known/oauth-protected-resource/api/mcp-routing/roomote"', + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"', ); await expect(mcpRoutingResponse.json()).resolves.toEqual( jsonRpcUnauthorized, @@ -217,14 +243,11 @@ describe('route policy enforcement', () => { }); it('rejects an MCP token whose audience does not match the configured resource', async () => { - const response = await createApiApp().request( - 'http://localhost/api/mcp-routing/roomote', - { - method: 'POST', - headers: { authorization: 'Bearer test-mcp-token' }, - body: '{}', - }, - ); + 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({ @@ -232,6 +255,89 @@ describe('route policy enforcement', () => { }); }); + 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 @@ -456,7 +562,7 @@ describe('route policy enforcement', () => { const mcpToken = { tokenType: 'mcp', userId: 'user-123', - resource: 'https://api.example.com/api/mcp-routing/roomote', + resource: 'https://api.example.com/mcp', scopes: ['mcp:roomote'], version: 1, } as Variables['authContext']; diff --git a/apps/api/src/handlers/index.ts b/apps/api/src/handlers/index.ts index 4be083395..93f8fb5a6 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -23,6 +23,7 @@ export { trpc } from './trpc'; 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 index 2d08aeb50..9001c5277 100644 --- a/apps/api/src/handlers/mcp-oauth.ts +++ b/apps/api/src/handlers/mcp-oauth.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE } from '@roomote/auth'; import { Env } from '@roomote/env'; @@ -6,11 +6,15 @@ import { Env } from '@roomote/env'; import type { Variables } from '../types'; const ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = + '/.well-known/oauth-protected-resource/mcp'; +const LEGACY_ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource/api/mcp-routing/roomote'; export const mcpOAuthMetadata = new Hono<{ Variables: Variables }>(); -mcpOAuthMetadata.get(ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, (c) => { +const protectedResourceMetadataHandler = ( + c: Context<{ Variables: Variables }>, +) => { const authorizationServer = Env.R_PUBLIC_URL ?? Env.R_APP_URL; c.header('Cache-Control', 'public, max-age=3600'); @@ -20,4 +24,13 @@ mcpOAuthMetadata.get(ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH, (c) => { 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 3857631ca..aadbec2b3 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( @@ -111,9 +119,13 @@ async function resolveRoomoteMcpAuth( if ( authContext.tokenType === 'mcp' && - authContext.resource === - new URL('/api/mcp-routing/roomote', Env.TRPC_URL).toString() && - authContext.scopes.includes('mcp:roomote') + [ + getRoomoteMcpResourceUrl(Env.TRPC_URL), + ...(options.allowLegacyAudience + ? [getLegacyRoomoteMcpResourceUrl(Env.TRPC_URL)] + : []), + ].includes(authContext.resource) && + authContext.scopes.includes(ROOMOTE_MCP_SCOPE) ) { return { userId: authContext.userId, @@ -358,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', { @@ -489,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 4cde6aea3..5dc172906 100644 --- a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts +++ b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts @@ -90,13 +90,13 @@ describe('tokenAuthMiddleware token extraction', () => { const mcpContext = { tokenType: 'mcp', userId: 'user-1', - resource: 'https://api.example.com/api/mcp-routing/roomote', + resource: 'https://api.example.com/mcp', scopes: ['mcp:roomote'], version: 1, }; mockValidateMcpAccessToken.mockResolvedValue(mcpContext); - const authContext = await requestAuthContext('/api/mcp-routing/roomote', { + const authContext = await requestAuthContext('/mcp', { authorization: 'Bearer valid-mcp-token', }); diff --git a/apps/api/src/middleware/routePolicyMiddleware.ts b/apps/api/src/middleware/routePolicyMiddleware.ts index 75f23bf3a..f70e37499 100644 --- a/apps/api/src/middleware/routePolicyMiddleware.ts +++ b/apps/api/src/middleware/routePolicyMiddleware.ts @@ -103,9 +103,12 @@ function rejectionResponse( rule: RoutePolicyRule, rejection: RoutePolicyRejection, ): Response { - if (rule.name === 'roomote-mcp' && rejection.status === 401) { + if ( + (rule.name === 'roomote-mcp' || rule.name === 'roomote-public-mcp') && + rejection.status === 401 + ) { const resourceMetadata = new URL( - '/.well-known/oauth-protected-resource/api/mcp-routing/roomote', + '/.well-known/oauth-protected-resource/mcp', c.req.url, ); c.header( diff --git a/apps/api/src/route-policies.ts b/apps/api/src/route-policies.ts index 7a48c0ea9..dd969651a 100644 --- a/apps/api/src/route-policies.ts +++ b/apps/api/src/route-policies.ts @@ -127,6 +127,14 @@ export const ROUTE_POLICY_RULES: readonly RoutePolicyRule[] = [ }, 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. { @@ -247,6 +255,12 @@ 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' }, diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 84d12cfb8..f17267fc8 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -50,6 +50,7 @@ import { mcp, mcpRouting, mcpOAuthMetadata, + publicRoomoteMcp, taskRunsRouter, artifactsRouter, taskArtifactsRouter, @@ -212,6 +213,7 @@ 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); diff --git a/apps/docs/integrations/remote-roomote-mcp.mdx b/apps/docs/integrations/remote-roomote-mcp.mdx index f56e31cb3..daef486e2 100644 --- a/apps/docs/integrations/remote-roomote-mcp.mdx +++ b/apps/docs/integrations/remote-roomote-mcp.mdx @@ -1,12 +1,12 @@ --- title: Remote Roomote MCP icon: plug-circle-bolt -description: Connect an OAuth-capable MCP client to Roomote's shared context tools. +description: Connect an OAuth-capable MCP client to Roomote's member task tools. --- -Roomote exposes its context and environment-discovery tools as a remote MCP -server. OAuth-capable clients can connect without manually creating or copying -an API token. +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 @@ -26,13 +26,21 @@ callback URLs must use HTTPS. Configure the MCP client with this server URL: ```text -/api/mcp-routing/roomote +/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. The client must support OAuth authorization code -flow with S256 PKCE and dynamic client registration. +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 currently issues a one-hour access token without a refresh token. When it expires, the client starts the browser authorization flow again. @@ -51,9 +59,11 @@ internal user and task-run credentials: - dynamically registered clients expire after 30 days and can register only HTTPS or loopback HTTP callback URLs -This endpoint exposes the same Roomote context tools used for routing before a -task starts. It does not grant a client a task-run identity or access to tools -that require an active Roomote task. +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 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 index ff30da1d2..5daee27ad 100644 --- 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 @@ -18,7 +18,7 @@ vi.mock('@/lib/server/mcp-remote-oauth', () => ({ createRemoteMcpAuthorizationCode: mockCreateCode, })); -import { GET } from '../route'; +import { GET, POST } from '../route'; const clientId = '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568'; const redirectUri = 'https://client.example/callback'; @@ -31,10 +31,7 @@ function authorizeRequest() { 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://api.example.com/api/mcp-routing/roomote', - ); + url.searchParams.set('resource', 'https://api.example.com/mcp'); url.searchParams.set('scope', 'mcp:roomote'); return new NextRequest(url); } @@ -44,6 +41,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { vi.clearAllMocks(); mockGetClient.mockResolvedValue({ clientId, + clientName: 'Claude Code', redirectUris: [redirectUri], }); }); @@ -62,11 +60,24 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { expect(mockCreateCode).not.toHaveBeenCalled(); }); - it('issues a resource-bound code for the signed-in user', async () => { + it('requires explicit approval before issuing a code', async () => { mockAuthorize.mockResolvedValue({ success: true, userId: 'user-1' }); - mockCreateCode.mockResolvedValue('authorization-code'); 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(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()); const location = new URL(response.headers.get('location')!); expect(location.toString()).toBe( @@ -77,7 +88,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { clientId, redirectUri, codeChallenge: 'a'.repeat(43), - resource: 'https://api.example.com/api/mcp-routing/roomote', + resource: 'https://api.example.com/mcp', scopes: ['mcp:roomote'], }); }); 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 index 136b326b0..00669e456 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -25,7 +25,67 @@ const authorizeSchema = z.object({ scope: z.string().optional(), }); -export async function GET(request: NextRequest) { +function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/g, + (character) => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!, + ); +} + +function consentResponse(options: { + request: NextRequest; + clientName?: string; + redirectUri: string; +}) { + const action = escapeHtml( + `${options.request.nextUrl.pathname}${options.request.nextUrl.search}`, + ); + const clientName = escapeHtml(options.clientName ?? 'An MCP client'); + const callbackHost = escapeHtml(new URL(options.redirectUri).host); + + return new NextResponse( + ` + + + + + Authorize ${clientName} + + +
+
+

Roomote MCP

+

Authorize ${clientName}?

+

This client will act as your signed-in Roomote member. It can read task and chat context, launch or cancel tasks, and send follow-up messages.

+
After approval, Roomote returns you to ${callbackHost}.
+
+ +
+
+
+ +`, + { + status: 200, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': + "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; 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( @@ -72,6 +132,14 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(signInUrl); } + if (!approved) { + return consentResponse({ + request, + clientName: client.clientName, + redirectUri: input.redirect_uri, + }); + } + const code = await createRemoteMcpAuthorizationCode({ userId: auth.userId, clientId: input.client_id, @@ -85,3 +153,11 @@ export async function GET(request: NextRequest) { redirect.searchParams.set('state', input.state); return NextResponse.redirect(redirect); } + +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 index 2437b6ba2..78f361026 100644 --- 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 @@ -1,9 +1,13 @@ import { NextRequest } from 'next/server'; -const mockRegisterClient = vi.hoisted(() => vi.fn()); +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, })); @@ -25,7 +29,10 @@ function registrationRequest(redirectUri: string) { } describe('POST /api/mcp-remote-oauth/register', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockRegistrationAllowed.mockResolvedValue(true); + }); it('registers an HTTPS callback for a public client', async () => { mockRegisterClient.mockResolvedValue({ @@ -56,4 +63,19 @@ describe('POST /api/mcp-remote-oauth/register', () => { }); expect(mockRegisterClient).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(); + }); }); 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 index 2bb92ef85..11e0a3606 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/register/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { isAllowedOAuthRedirectUri, + isRemoteMcpRegistrationAllowed, registerRemoteMcpOAuthClient, } from '@/lib/server/mcp-remote-oauth'; @@ -21,6 +22,25 @@ const registrationSchema = z.object({ }); export async function POST(request: NextRequest) { + const clientIdentifier = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip')?.trim() || + 'unknown'; + + try { + if (!(await isRemoteMcpRegistrationAllowed(clientIdentifier))) { + return NextResponse.json( + { error: 'temporarily_unavailable' }, + { status: 429, headers: { 'Retry-After': '3600' } }, + ); + } + } catch { + return NextResponse.json( + { error: 'temporarily_unavailable' }, + { status: 503 }, + ); + } + let body: unknown; try { body = await request.json(); 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 index 811ff1707..265110e5d 100644 --- 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 @@ -1,7 +1,8 @@ import { createHash } from 'node:crypto'; import { NextRequest } from 'next/server'; -const { mockConsumeCode, mockCreateToken } = vi.hoisted(() => ({ +const { mockGetCode, mockConsumeCode, mockCreateToken } = vi.hoisted(() => ({ + mockGetCode: vi.fn(), mockConsumeCode: vi.fn(), mockCreateToken: vi.fn(), })); @@ -13,6 +14,7 @@ vi.mock('@roomote/auth', async (importOriginal) => ({ vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ ...(await importOriginal()), + getRemoteMcpAuthorizationCode: mockGetCode, consumeRemoteMcpAuthorizationCode: mockConsumeCode, })); @@ -21,7 +23,7 @@ import { POST } from '../route'; const verifier = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~'; const challenge = createHash('sha256').update(verifier).digest('base64url'); -const resource = 'https://api.example.com/api/mcp-routing/roomote'; +const resource = 'https://api.example.com/mcp'; function tokenRequest(overrides: Record = {}) { const body = new URLSearchParams({ @@ -43,7 +45,7 @@ function tokenRequest(overrides: Record = {}) { describe('POST /api/mcp-remote-oauth/token', () => { beforeEach(() => { vi.clearAllMocks(); - mockConsumeCode.mockResolvedValue({ + mockGetCode.mockResolvedValue({ userId: 'user-1', clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', redirectUri: 'https://client.example/callback', @@ -51,6 +53,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { resource, scopes: ['mcp:roomote'], }); + mockConsumeCode.mockResolvedValue(true); mockCreateToken.mockResolvedValue('access-token'); }); @@ -71,6 +74,10 @@ describe('POST /api/mcp-remote-oauth/token', () => { scopes: ['mcp:roomote'], timeoutMs: 3_600_000, }); + expect(mockConsumeCode).toHaveBeenCalledWith( + 'authorization-code', + expect.objectContaining({ userId: 'user-1' }), + ); }); it('rejects a verifier that does not match the authorization code', async () => { @@ -83,6 +90,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); + expect(mockConsumeCode).not.toHaveBeenCalled(); expect(mockCreateToken).not.toHaveBeenCalled(); }); @@ -99,7 +107,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { await expect(response.json()).resolves.toEqual({ error: 'invalid_request', }); - expect(mockConsumeCode).not.toHaveBeenCalled(); + expect(mockGetCode).not.toHaveBeenCalled(); }); it('requires the token request to repeat the bound resource', async () => { @@ -109,6 +117,6 @@ describe('POST /api/mcp-remote-oauth/token', () => { await expect(response.json()).resolves.toEqual({ error: 'invalid_request', }); - expect(mockConsumeCode).not.toHaveBeenCalled(); + 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 index 7e7481f07..fcfb054f8 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/token/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/token/route.ts @@ -9,6 +9,7 @@ import { import { consumeRemoteMcpAuthorizationCode, + getRemoteMcpAuthorizationCode, verifyPkceChallenge, } from '@/lib/server/mcp-remote-oauth'; @@ -44,7 +45,7 @@ export async function POST(request: NextRequest) { } const input = parsed.data; - const authorization = await consumeRemoteMcpAuthorizationCode(input.code); + const authorization = await getRemoteMcpAuthorizationCode(input.code); if ( !authorization || authorization.clientId !== input.client_id || @@ -57,6 +58,10 @@ export async function POST(request: NextRequest) { return oauthError('invalid_grant'); } + if (!(await consumeRemoteMcpAuthorizationCode(input.code, authorization))) { + return oauthError('invalid_grant'); + } + const accessToken = await createMcpAccessToken({ userId: authorization.userId, resource: authorization.resource, diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts index 835f25b57..95b9c0a7a 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -7,10 +7,21 @@ vi.mock('@roomote/redis', () => ({ return 'OK'; }, get: async (key: string) => redisState.get(key) ?? null, - getdel: async (key: string) => { - const value = redisState.get(key) ?? null; - redisState.delete(key); - return value; + eval: async ( + script: string, + _keyCount: number, + key: string, + arg: string, + ) => { + if (script.includes("redis.call('GET'")) { + const value = redisState.get(key) ?? null; + if (value === arg) redisState.delete(key); + return value === arg ? value : null; + } + + const count = Number(redisState.get(key) ?? '0') + 1; + redisState.set(key, String(count)); + return count; }, }), })); @@ -18,7 +29,9 @@ vi.mock('@roomote/redis', () => ({ import { consumeRemoteMcpAuthorizationCode, createRemoteMcpAuthorizationCode, + getRemoteMcpAuthorizationCode, isAllowedOAuthRedirectUri, + isRemoteMcpRegistrationAllowed, registerRemoteMcpOAuthClient, verifyPkceChallenge, } from './mcp-remote-oauth'; @@ -56,15 +69,49 @@ describe('remote MCP OAuth state', () => { clientId: 'client-1', redirectUri: 'https://client.example/callback', codeChallenge: 'challenge', - resource: 'https://api.example.com/api/mcp-routing/roomote', + 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)).resolves.toEqual( - value, + await expect( + consumeRemoteMcpAuthorizationCode(code, { + ...value, + codeChallenge: 'wrong-challenge', + }), + ).resolves.toBe(false); + await expect(getRemoteMcpAuthorizationCode(code)).resolves.toEqual(value); + }); + + it('bounds registrations per client and globally', async () => { + const allowed = await Promise.all( + Array.from({ length: 21 }, () => + isRemoteMcpRegistrationAllowed('203.0.113.5'), + ), ); - await expect(consumeRemoteMcpAuthorizationCode(code)).resolves.toBeNull(); + + expect(allowed.slice(0, 20).every(Boolean)).toBe(true); + expect(allowed[20]).toBe(false); }); it('verifies S256 PKCE challenges', () => { diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index 54637dce5..01bf6856d 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -4,8 +4,29 @@ import { getRedis } from '@roomote/redis'; const CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; const AUTHORIZATION_CODE_TTL_SECONDS = 5 * 60; +const REGISTRATION_RATE_LIMIT_WINDOW_SECONDS = 60 * 60; +const REGISTRATION_RATE_LIMIT_PER_CLIENT = 20; +const REGISTRATION_RATE_LIMIT_GLOBAL = 1_000; const CLIENT_KEY_PREFIX = 'mcp-remote-oauth:client:'; const CODE_KEY_PREFIX = 'mcp-remote-oauth:code:'; +const REGISTRATION_RATE_KEY_PREFIX = 'mcp-remote-oauth:registration-rate:'; + +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 RATE_LIMIT_INCREMENT_LUA = ` +local current = redis.call('INCR', KEYS[1]) +if current == 1 then + redis.call('EXPIRE', KEYS[1], ARGV[1]) +end +return current +`; type RemoteMcpOAuthClient = { clientId: string; @@ -86,13 +107,59 @@ export async function createRemoteMcpAuthorizationCode( return code; } -export async function consumeRemoteMcpAuthorizationCode( +export async function getRemoteMcpAuthorizationCode( code: string, ): Promise { - const value = await getRedis().getdel(codeKey(code)); + 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'; +} + +async function incrementRegistrationBucket(key: string): Promise { + return getRedis().eval( + RATE_LIMIT_INCREMENT_LUA, + 1, + key, + String(REGISTRATION_RATE_LIMIT_WINDOW_SECONDS), + ) as Promise; +} + +export async function isRemoteMcpRegistrationAllowed( + clientIdentifier: string, +): Promise { + const window = Math.floor( + Date.now() / (REGISTRATION_RATE_LIMIT_WINDOW_SECONDS * 1000), + ); + const clientHash = createHash('sha256') + .update(clientIdentifier) + .digest('hex'); + const [clientCount, globalCount] = await Promise.all([ + incrementRegistrationBucket( + `${REGISTRATION_RATE_KEY_PREFIX}client:${clientHash}:${window}`, + ), + incrementRegistrationBucket( + `${REGISTRATION_RATE_KEY_PREFIX}global:${window}`, + ), + ]); + + return ( + clientCount <= REGISTRATION_RATE_LIMIT_PER_CLIENT && + globalCount <= REGISTRATION_RATE_LIMIT_GLOBAL + ); +} + export function verifyPkceChallenge( verifier: string, expectedChallenge: string, diff --git a/packages/auth/src/__tests__/mcp-access-token.test.ts b/packages/auth/src/__tests__/mcp-access-token.test.ts index 52eac8c88..5b7064c4e 100644 --- a/packages/auth/src/__tests__/mcp-access-token.test.ts +++ b/packages/auth/src/__tests__/mcp-access-token.test.ts @@ -32,7 +32,7 @@ import { validateMcpAccessToken, } from '../mcp-access-token'; -const resource = 'https://api.example.com/api/mcp-routing/roomote'; +const resource = 'https://api.example.com/mcp'; describe('MCP access tokens', () => { beforeEach(() => vi.clearAllMocks()); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 92dcec649..993064bbf 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -24,9 +24,11 @@ export { export { type CreateMcpAccessTokenOptions, DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, + ROOMOTE_MCP_LEGACY_PATH, ROOMOTE_MCP_PATH, ROOMOTE_MCP_SCOPE, createMcpAccessToken, + getLegacyRoomoteMcpResourceUrl, getRoomoteMcpResourceUrl, validateMcpAccessToken, } from './mcp-access-token'; diff --git a/packages/auth/src/mcp-access-token.ts b/packages/auth/src/mcp-access-token.ts index 0a4df3053..29abb8124 100644 --- a/packages/auth/src/mcp-access-token.ts +++ b/packages/auth/src/mcp-access-token.ts @@ -19,13 +19,18 @@ import { const ISSUER = 'rcc'; export const ROOMOTE_MCP_SCOPE = 'mcp:roomote'; -export const ROOMOTE_MCP_PATH = '/api/mcp-routing/roomote'; +export const ROOMOTE_MCP_PATH = '/mcp'; +export const ROOMOTE_MCP_LEGACY_PATH = '/api/mcp-routing/roomote'; export const DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS = 60 * 60 * 1000; export function getRoomoteMcpResourceUrl(apiBaseUrl: string): string { return new URL(ROOMOTE_MCP_PATH, apiBaseUrl).toString(); } +export function getLegacyRoomoteMcpResourceUrl(apiBaseUrl: string): string { + return new URL(ROOMOTE_MCP_LEGACY_PATH, apiBaseUrl).toString(); +} + const createMcpAccessTokenOptionsSchema = z.object({ userId: z.string().min(1), resource: z.string().url(), From e3f011f837ebfb7437799d5f7bbb040ace87041e Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:08:33 +0000 Subject: [PATCH 04/15] fix: bind OAuth consent and cap registrations --- .../authorize/__tests__/route.test.ts | 57 ++++++++++++++-- .../api/mcp-remote-oauth/authorize/route.ts | 28 ++++++++ .../register/__tests__/route.test.ts | 25 +++++++ .../api/mcp-remote-oauth/register/route.ts | 20 ++++-- .../src/lib/server/mcp-remote-oauth.test.ts | 59 ++++++++++++++--- apps/web/src/lib/server/mcp-remote-oauth.ts | 66 ++++++++++++++++++- 6 files changed, 233 insertions(+), 22 deletions(-) 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 index 5daee27ad..a533d6cc0 100644 --- 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 @@ -1,9 +1,17 @@ import { NextRequest } from 'next/server'; -const { mockAuthorize, mockGetClient, mockCreateCode } = vi.hoisted(() => ({ +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 })); @@ -16,6 +24,8 @@ vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ vi.mock('@/lib/server/mcp-remote-oauth', () => ({ getRemoteMcpOAuthClient: mockGetClient, createRemoteMcpAuthorizationCode: mockCreateCode, + createRemoteMcpConsentToken: mockCreateConsentToken, + consumeRemoteMcpConsentToken: mockConsumeConsentToken, })); import { GET, POST } from '../route'; @@ -23,7 +33,10 @@ import { GET, POST } from '../route'; const clientId = '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568'; const redirectUri = 'https://client.example/callback'; -function authorizeRequest() { +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); @@ -33,7 +46,14 @@ function authorizeRequest() { url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('resource', 'https://api.example.com/mcp'); url.searchParams.set('scope', 'mcp:roomote'); - return new NextRequest(url); + 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', () => { @@ -44,6 +64,8 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { clientName: 'Claude Code', redirectUris: [redirectUri], }); + mockCreateConsentToken.mockResolvedValue('consent-token'); + mockConsumeConsentToken.mockResolvedValue(true); }); it('continues through browser sign-in before issuing a code', async () => { @@ -70,6 +92,13 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { expect(response.headers.get('content-type')).toContain('text/html'); expect(html).toContain('Authorize Claude Code?'); expect(html).toContain('Allow access'); + expect(html).toContain('name="consent_token" value="consent-token"'); + expect(mockCreateConsentToken).toHaveBeenCalledWith({ + userId: 'user-1', + requestTarget: expect.stringContaining( + '/api/mcp-remote-oauth/authorize?', + ), + }); expect(mockCreateCode).not.toHaveBeenCalled(); }); @@ -77,7 +106,9 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { mockAuthorize.mockResolvedValue({ success: true, userId: 'user-1' }); mockCreateCode.mockResolvedValue('authorization-code'); - const response = await POST(authorizeRequest()); + const response = await POST( + authorizeRequest({ approved: true, consentToken: 'consent-token' }), + ); const location = new URL(response.headers.get('location')!); expect(location.toString()).toBe( @@ -91,5 +122,23 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { resource: 'https://api.example.com/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 index 00669e456..73c9101b7 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -8,6 +8,8 @@ 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'; @@ -42,6 +44,7 @@ function escapeHtml(value: string): string { function consentResponse(options: { request: NextRequest; clientName?: string; + consentToken: string; redirectUri: string; }) { const action = escapeHtml( @@ -49,6 +52,7 @@ function consentResponse(options: { ); const clientName = escapeHtml(options.clientName ?? 'An MCP client'); const callbackHost = escapeHtml(new URL(options.redirectUri).host); + const consentToken = escapeHtml(options.consentToken); return new NextResponse( ` @@ -66,6 +70,7 @@ function consentResponse(options: {

This client will act as your signed-in Roomote member. It can read task and chat context, launch or cancel tasks, and send follow-up messages.

After approval, Roomote returns you to ${callbackHost}.
+
@@ -132,14 +137,37 @@ async function handleAuthorize(request: NextRequest, approved: boolean) { 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, 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 index 78f361026..fdfd5f0f2 100644 --- 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 @@ -78,4 +78,29 @@ describe('POST /api/mcp-remote-oauth/register', () => { }); 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 index 11e0a3606..aa35ed67e 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/register/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -12,9 +12,9 @@ export const runtime = 'nodejs'; const registrationSchema = z.object({ client_name: z.string().trim().min(1).max(200).optional(), redirect_uris: z - .array(z.string()) + .array(z.string().max(1_024)) .min(1) - .max(10) + .max(5) .refine((values) => values.every(isAllowedOAuthRedirectUri)), token_endpoint_auth_method: z.literal('none').optional(), grant_types: z.array(z.literal('authorization_code')).optional(), @@ -59,10 +59,18 @@ export async function POST(request: NextRequest) { ); } - const client = await registerRemoteMcpOAuthClient({ - clientName: parsed.data.client_name, - redirectUris: parsed.data.redirect_uris, - }); + let client; + try { + client = await registerRemoteMcpOAuthClient({ + clientName: parsed.data.client_name, + redirectUris: parsed.data.redirect_uris, + }); + } catch { + return NextResponse.json( + { error: 'temporarily_unavailable' }, + { status: 503 }, + ); + } return NextResponse.json( { diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts index 95b9c0a7a..e442b63cf 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -1,4 +1,7 @@ const redisState = vi.hoisted(() => new Map()); +const redisSortedSets = vi.hoisted( + () => new Map>(), +); vi.mock('@roomote/redis', () => ({ getRedis: () => ({ @@ -7,16 +10,28 @@ vi.mock('@roomote/redis', () => ({ return 'OK'; }, get: async (key: string) => redisState.get(key) ?? null, - eval: async ( - script: string, - _keyCount: number, - key: string, - arg: string, - ) => { + 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('ZREMRANGEBYSCORE'")) { + 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("redis.call('GET'")) { const value = redisState.get(key) ?? null; - if (value === arg) redisState.delete(key); - return value === arg ? value : null; + if (value === values[0]) redisState.delete(key); + return value === values[0] ? value : null; } const count = Number(redisState.get(key) ?? '0') + 1; @@ -28,7 +43,9 @@ vi.mock('@roomote/redis', () => ({ import { consumeRemoteMcpAuthorizationCode, + consumeRemoteMcpConsentToken, createRemoteMcpAuthorizationCode, + createRemoteMcpConsentToken, getRemoteMcpAuthorizationCode, isAllowedOAuthRedirectUri, isRemoteMcpRegistrationAllowed, @@ -37,7 +54,10 @@ import { } from './mcp-remote-oauth'; describe('remote MCP OAuth state', () => { - beforeEach(() => redisState.clear()); + beforeEach(() => { + redisState.clear(); + redisSortedSets.clear(); + }); it('accepts HTTPS and loopback redirects only', () => { expect(isAllowedOAuthRedirectUri('https://client.example/callback')).toBe( @@ -103,6 +123,27 @@ describe('remote MCP OAuth state', () => { 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('bounds registrations per client and globally', async () => { const allowed = await Promise.all( Array.from({ length: 21 }, () => diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index 01bf6856d..cc7721845 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -4,12 +4,16 @@ import { getRedis } from '@roomote/redis'; const CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; 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 = 1_000; +const MAX_REGISTERED_CLIENTS = 1_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 CONSUME_CODE_LUA = ` local value = redis.call('GET', KEYS[1]) @@ -28,6 +32,16 @@ end return current `; +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 +`; + type RemoteMcpOAuthClient = { clientId: string; clientName?: string; @@ -43,6 +57,11 @@ type RemoteMcpAuthorizationCode = { scopes: string[]; }; +type RemoteMcpConsentBinding = { + userId: string; + requestTarget: string; +}; + function clientKey(clientId: string): string { return `${CLIENT_KEY_PREFIX}${clientId}`; } @@ -51,6 +70,10 @@ function codeKey(code: string): string { return `${CODE_KEY_PREFIX}${code}`; } +function consentKey(token: string): string { + return `${CONSENT_KEY_PREFIX}${token}`; +} + export function isAllowedOAuthRedirectUri(value: string): boolean { try { const url = new URL(value); @@ -77,12 +100,22 @@ export async function registerRemoteMcpOAuthClient(input: { redirectUris: input.redirectUris, }; - await getRedis().set( + 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), - 'EX', - CLIENT_TTL_SECONDS, + String(CLIENT_TTL_SECONDS), + String(now + 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; } @@ -107,6 +140,20 @@ export async function createRemoteMcpAuthorizationCode( 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 { @@ -127,6 +174,19 @@ export async function consumeRemoteMcpAuthorizationCode( 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'; +} + async function incrementRegistrationBucket(key: string): Promise { return getRedis().eval( RATE_LIMIT_INCREMENT_LUA, From e513b31722fc67bd221c38045879be194ca72c47 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:12:51 +0000 Subject: [PATCH 05/15] fix: bound OAuth rate limit keys --- .../src/lib/server/mcp-remote-oauth.test.ts | 14 ++++++++++++++ apps/web/src/lib/server/mcp-remote-oauth.ts | 19 ++++++++----------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts index e442b63cf..3a85bf0a7 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -155,6 +155,20 @@ describe('remote MCP OAuth state', () => { expect(allowed[20]).toBe(false); }); + it('does not allocate client buckets after the global limit is full', async () => { + for (let index = 0; index < 1_000; 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('verifies S256 PKCE challenges', () => { expect( verifyPkceChallenge( diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index cc7721845..1bf422706 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -205,19 +205,16 @@ export async function isRemoteMcpRegistrationAllowed( const clientHash = createHash('sha256') .update(clientIdentifier) .digest('hex'); - const [clientCount, globalCount] = await Promise.all([ - incrementRegistrationBucket( - `${REGISTRATION_RATE_KEY_PREFIX}client:${clientHash}:${window}`, - ), - incrementRegistrationBucket( - `${REGISTRATION_RATE_KEY_PREFIX}global:${window}`, - ), - ]); + const globalCount = await incrementRegistrationBucket( + `${REGISTRATION_RATE_KEY_PREFIX}global:${window}`, + ); + if (globalCount > REGISTRATION_RATE_LIMIT_GLOBAL) return false; - return ( - clientCount <= REGISTRATION_RATE_LIMIT_PER_CLIENT && - globalCount <= REGISTRATION_RATE_LIMIT_GLOBAL + const clientCount = await incrementRegistrationBucket( + `${REGISTRATION_RATE_KEY_PREFIX}client:${clientHash}:${window}`, ); + + return clientCount <= REGISTRATION_RATE_LIMIT_PER_CLIENT; } export function verifyPkceChallenge( From 30380adf3b8b5ce1020a957306fd50fa1bcbeef7 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:25:38 +0000 Subject: [PATCH 06/15] fix: harden OAuth client admission --- apps/docs/integrations/remote-roomote-mcp.mdx | 3 +- .../register/__tests__/route.test.ts | 7 ++ .../api/mcp-remote-oauth/register/route.ts | 37 +++--- .../token/__tests__/route.test.ts | 29 ++++- .../app/api/mcp-remote-oauth/token/route.ts | 10 ++ .../src/lib/server/mcp-remote-oauth.test.ts | 91 +++++++++++++-- apps/web/src/lib/server/mcp-remote-oauth.ts | 105 +++++++++++++----- 7 files changed, 222 insertions(+), 60 deletions(-) diff --git a/apps/docs/integrations/remote-roomote-mcp.mdx b/apps/docs/integrations/remote-roomote-mcp.mdx index daef486e2..aabe0460e 100644 --- a/apps/docs/integrations/remote-roomote-mcp.mdx +++ b/apps/docs/integrations/remote-roomote-mcp.mdx @@ -56,7 +56,8 @@ internal user and task-run credentials: 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 -- dynamically registered clients expire after 30 days and can register only +- 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 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 index fdfd5f0f2..58448472d 100644 --- 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 @@ -50,6 +50,12 @@ describe('POST /api/mcp-remote-oauth/register', () => { 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'], + }), + ); }); it('rejects a non-loopback HTTP callback', async () => { @@ -62,6 +68,7 @@ describe('POST /api/mcp-remote-oauth/register', () => { error: 'invalid_client_metadata', }); expect(mockRegisterClient).not.toHaveBeenCalled(); + expect(mockRegistrationAllowed).not.toHaveBeenCalled(); }); it('rate limits anonymous client registration before writing Redis', async () => { 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 index aa35ed67e..bdcf4d935 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/register/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -22,25 +22,6 @@ const registrationSchema = z.object({ }); export async function POST(request: NextRequest) { - const clientIdentifier = - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip')?.trim() || - 'unknown'; - - try { - if (!(await isRemoteMcpRegistrationAllowed(clientIdentifier))) { - return NextResponse.json( - { error: 'temporarily_unavailable' }, - { status: 429, headers: { 'Retry-After': '3600' } }, - ); - } - } catch { - return NextResponse.json( - { error: 'temporarily_unavailable' }, - { status: 503 }, - ); - } - let body: unknown; try { body = await request.json(); @@ -59,6 +40,24 @@ export async function POST(request: NextRequest) { ); } + const registrationFingerprint = JSON.stringify({ + clientName: parsed.data.client_name, + redirectUris: parsed.data.redirect_uris, + }); + 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({ 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 index 265110e5d..9f48613b8 100644 --- 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 @@ -1,11 +1,13 @@ import { createHash } from 'node:crypto'; import { NextRequest } from 'next/server'; -const { mockGetCode, mockConsumeCode, mockCreateToken } = vi.hoisted(() => ({ - mockGetCode: vi.fn(), - mockConsumeCode: vi.fn(), - mockCreateToken: vi.fn(), -})); +const { mockGetCode, mockConsumeCode, mockPromoteClient, mockCreateToken } = + vi.hoisted(() => ({ + mockGetCode: vi.fn(), + mockConsumeCode: vi.fn(), + mockPromoteClient: vi.fn(), + mockCreateToken: vi.fn(), + })); vi.mock('@roomote/auth', async (importOriginal) => ({ ...(await importOriginal()), @@ -16,6 +18,7 @@ vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ ...(await importOriginal()), getRemoteMcpAuthorizationCode: mockGetCode, consumeRemoteMcpAuthorizationCode: mockConsumeCode, + promoteRemoteMcpOAuthClient: mockPromoteClient, })); import { POST } from '../route'; @@ -54,6 +57,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { scopes: ['mcp:roomote'], }); mockConsumeCode.mockResolvedValue(true); + mockPromoteClient.mockResolvedValue(true); mockCreateToken.mockResolvedValue('access-token'); }); @@ -78,6 +82,10 @@ describe('POST /api/mcp-remote-oauth/token', () => { 'authorization-code', expect.objectContaining({ userId: 'user-1' }), ); + expect(mockPromoteClient).toHaveBeenCalledWith( + '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + 'user-1', + ); }); it('rejects a verifier that does not match the authorization code', async () => { @@ -94,6 +102,17 @@ describe('POST /api/mcp-remote-oauth/token', () => { 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', { 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 index fcfb054f8..3fe7c348d 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/token/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/token/route.ts @@ -10,6 +10,7 @@ import { import { consumeRemoteMcpAuthorizationCode, getRemoteMcpAuthorizationCode, + promoteRemoteMcpOAuthClient, verifyPkceChallenge, } from '@/lib/server/mcp-remote-oauth'; @@ -58,6 +59,15 @@ export async function POST(request: NextRequest) { 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'); } diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts index 3a85bf0a7..cc834fcb5 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -14,7 +14,7 @@ vi.mock('@roomote/redis', () => ({ const keys = args.slice(0, keyCount); const values = args.slice(keyCount); const key = keys[0]!; - if (script.includes("redis.call('ZREMRANGEBYSCORE'")) { + if (script.includes("redis.call('SET', KEYS[1]")) { const indexKey = keys[1]!; const [clientJson, , expiresAt, clientId, maxClients, now] = values; const clients = redisSortedSets.get(indexKey) ?? new Map(); @@ -28,6 +28,54 @@ vi.mock('@roomote/redis', () => ({ 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); @@ -49,6 +97,7 @@ import { getRemoteMcpAuthorizationCode, isAllowedOAuthRedirectUri, isRemoteMcpRegistrationAllowed, + promoteRemoteMcpOAuthClient, registerRemoteMcpOAuthClient, verifyPkceChallenge, } from './mcp-remote-oauth'; @@ -81,6 +130,14 @@ describe('remote MCP OAuth state', () => { clientName: 'Test client', redirectUris: ['https://client.example/callback'], }); + 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 () => { @@ -145,18 +202,20 @@ describe('remote MCP OAuth state', () => { }); it('bounds registrations per client and globally', async () => { - const allowed = await Promise.all( - Array.from({ length: 21 }, () => - isRemoteMcpRegistrationAllowed('203.0.113.5'), - ), - ); + 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 < 1_000; index += 1) { + for (let index = 0; index < 100; index += 1) { await expect( isRemoteMcpRegistrationAllowed(`client-${index}`), ).resolves.toBe(true); @@ -169,6 +228,24 @@ describe('remote MCP OAuth state', () => { 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( diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index 1bf422706..3971114c2 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -2,18 +2,23 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto'; import { getRedis } from '@roomote/redis'; -const CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; +const PENDING_CLIENT_TTL_SECONDS = 60 * 60; +const ACTIVE_CLIENT_TTL_SECONDS = 30 * 24 * 60 * 60; 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 = 1_000; -const MAX_REGISTERED_CLIENTS = 1_000; +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 CONSUME_CODE_LUA = ` local value = redis.call('GET', KEYS[1]) @@ -24,12 +29,21 @@ end return false `; -const RATE_LIMIT_INCREMENT_LUA = ` -local current = redis.call('INCR', KEYS[1]) -if current == 1 then +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 -return current +if global == 1 then + redis.call('EXPIRE', KEYS[2], ARGV[1]) +end +return 1 `; const REGISTER_CLIENT_LUA = ` @@ -42,6 +56,27 @@ 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 +`; + type RemoteMcpOAuthClient = { clientId: string; clientName?: string; @@ -107,8 +142,8 @@ export async function registerRemoteMcpOAuthClient(input: { clientKey(client.clientId), REGISTERED_CLIENTS_KEY, JSON.stringify(client), - String(CLIENT_TTL_SECONDS), - String(now + CLIENT_TTL_SECONDS), + String(PENDING_CLIENT_TTL_SECONDS), + String(now + PENDING_CLIENT_TTL_SECONDS), client.clientId, String(MAX_REGISTERED_CLIENTS), String(now), @@ -119,6 +154,29 @@ export async function registerRemoteMcpOAuthClient(input: { 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 { @@ -187,34 +245,25 @@ export async function consumeRemoteMcpConsentToken( return typeof value === 'string'; } -async function incrementRegistrationBucket(key: string): Promise { - return getRedis().eval( - RATE_LIMIT_INCREMENT_LUA, - 1, - key, - String(REGISTRATION_RATE_LIMIT_WINDOW_SECONDS), - ) as Promise; -} - export async function isRemoteMcpRegistrationAllowed( - clientIdentifier: string, + registrationFingerprint: string, ): Promise { const window = Math.floor( Date.now() / (REGISTRATION_RATE_LIMIT_WINDOW_SECONDS * 1000), ); const clientHash = createHash('sha256') - .update(clientIdentifier) + .update(registrationFingerprint) .digest('hex'); - const globalCount = await incrementRegistrationBucket( - `${REGISTRATION_RATE_KEY_PREFIX}global:${window}`, - ); - if (globalCount > REGISTRATION_RATE_LIMIT_GLOBAL) return false; - - const clientCount = await incrementRegistrationBucket( + 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 clientCount <= REGISTRATION_RATE_LIMIT_PER_CLIENT; + return admitted === 1; } export function verifyPkceChallenge( From d59014d071cfb7e32b1ab2458518360f2d7fb3e5 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:58:38 +0000 Subject: [PATCH 07/15] fix: route public MCP through OAuth --- .../route-policy-enforcement.test.ts | 19 +-- apps/api/src/handlers/mcp-oauth.ts | 10 +- apps/api/src/handlers/mcp/roomote.ts | 2 +- .../src/middleware/routePolicyMiddleware.ts | 8 +- apps/docs/integrations/remote-roomote-mcp.mdx | 20 +-- .../oauth-protected-resource/mcp/route.ts | 7 + .../authorize/__tests__/route.test.ts | 4 +- .../api/mcp-remote-oauth/authorize/route.ts | 4 +- apps/web/src/app/mcp/__tests__/route.test.ts | 136 ++++++++++++++++++ apps/web/src/app/mcp/route.ts | 9 ++ apps/web/src/lib/server/remote-mcp-proxy.ts | 69 +++++++++ .../src/__tests__/mcp-access-token.test.ts | 17 +++ packages/auth/src/index.ts | 2 + packages/auth/src/mcp-access-token.ts | 23 ++- 14 files changed, 298 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/app/.well-known/oauth-protected-resource/mcp/route.ts create mode 100644 apps/web/src/app/mcp/__tests__/route.test.ts create mode 100644 apps/web/src/app/mcp/route.ts create mode 100644 apps/web/src/lib/server/remote-mcp-proxy.ts diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index c7b394e31..bad687366 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -1,5 +1,9 @@ import type { Context, Next } from 'hono'; -import { getRoomoteMcpResourceUrl } from '@roomote/auth'; +import { + getLegacyRoomoteMcpResourceUrl, + getRoomoteMcpProtectedResourceMetadataUrl, + getRoomoteMcpResourceUrl, +} from '@roomote/auth'; import { Env } from '@roomote/env'; import type { Variables } from '../types'; @@ -81,7 +85,9 @@ vi.mock('../middleware', async (importOriginal) => { c.set('authContext', { tokenType: 'mcp', userId: 'user-123', - resource: getRoomoteMcpResourceUrl(Env.TRPC_URL), + resource: getRoomoteMcpResourceUrl( + Env.R_PUBLIC_URL ?? Env.R_APP_URL, + ), scopes: ['mcp:roomote'], version: 1, } as Variables['authContext']); @@ -101,10 +107,7 @@ vi.mock('../middleware', async (importOriginal) => { c.set('authContext', { tokenType: 'mcp', userId: 'user-123', - resource: new URL( - '/api/mcp-routing/roomote', - Env.TRPC_URL, - ).toString(), + resource: getLegacyRoomoteMcpResourceUrl(Env.TRPC_URL), scopes: ['mcp:roomote'], version: 1, } as Variables['authContext']); @@ -156,7 +159,7 @@ describe('route policy enforcement', () => { expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ - resource: expect.stringMatching(/\/mcp$/), + resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), authorization_servers: [expect.any(String)], bearer_methods_supported: ['header'], scopes_supported: ['mcp:roomote'], @@ -223,7 +226,7 @@ describe('route policy enforcement', () => { expect(mcpRoutingResponse.status).toBe(401); expect(mcpRoutingResponse.headers.get('www-authenticate')).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"', + `Bearer resource_metadata="${getRoomoteMcpProtectedResourceMetadataUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL)}"`, ); await expect(mcpRoutingResponse.json()).resolves.toEqual( jsonRpcUnauthorized, diff --git a/apps/api/src/handlers/mcp-oauth.ts b/apps/api/src/handlers/mcp-oauth.ts index 9001c5277..6dc651277 100644 --- a/apps/api/src/handlers/mcp-oauth.ts +++ b/apps/api/src/handlers/mcp-oauth.ts @@ -1,12 +1,14 @@ import { Hono, type Context } from 'hono'; -import { getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE } from '@roomote/auth'; +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 ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = - '/.well-known/oauth-protected-resource/mcp'; const LEGACY_ROOMOTE_MCP_PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource/api/mcp-routing/roomote'; @@ -19,7 +21,7 @@ const protectedResourceMetadataHandler = ( c.header('Cache-Control', 'public, max-age=3600'); return c.json({ - resource: getRoomoteMcpResourceUrl(Env.TRPC_URL), + resource: getRoomoteMcpResourceUrl(authorizationServer), authorization_servers: [new URL(authorizationServer).origin], bearer_methods_supported: ['header'], scopes_supported: [ROOMOTE_MCP_SCOPE], diff --git a/apps/api/src/handlers/mcp/roomote.ts b/apps/api/src/handlers/mcp/roomote.ts index aadbec2b3..38f972288 100644 --- a/apps/api/src/handlers/mcp/roomote.ts +++ b/apps/api/src/handlers/mcp/roomote.ts @@ -120,7 +120,7 @@ async function resolveRoomoteMcpAuth( if ( authContext.tokenType === 'mcp' && [ - getRoomoteMcpResourceUrl(Env.TRPC_URL), + getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), ...(options.allowLegacyAudience ? [getLegacyRoomoteMcpResourceUrl(Env.TRPC_URL)] : []), diff --git a/apps/api/src/middleware/routePolicyMiddleware.ts b/apps/api/src/middleware/routePolicyMiddleware.ts index f70e37499..72391b267 100644 --- a/apps/api/src/middleware/routePolicyMiddleware.ts +++ b/apps/api/src/middleware/routePolicyMiddleware.ts @@ -5,6 +5,8 @@ import { createMiddleware } from 'hono/factory'; 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 { @@ -107,13 +109,9 @@ function rejectionResponse( (rule.name === 'roomote-mcp' || rule.name === 'roomote-public-mcp') && rejection.status === 401 ) { - const resourceMetadata = new URL( - '/.well-known/oauth-protected-resource/mcp', - c.req.url, - ); c.header( 'WWW-Authenticate', - `Bearer resource_metadata="${resourceMetadata.toString()}"`, + `Bearer resource_metadata="${getRoomoteMcpProtectedResourceMetadataUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL)}"`, ); } diff --git a/apps/docs/integrations/remote-roomote-mcp.mdx b/apps/docs/integrations/remote-roomote-mcp.mdx index aabe0460e..f14c39252 100644 --- a/apps/docs/integrations/remote-roomote-mcp.mdx +++ b/apps/docs/integrations/remote-roomote-mcp.mdx @@ -10,13 +10,9 @@ manually creating or copying an API token. ## Prerequisites -Before connecting a client: - -- `TRPC_URL` must be a browser-reachable HTTPS API origin -- `R_PUBLIC_URL`, or `R_APP_URL` when no public URL is set, must be a - browser-reachable HTTPS web origin -- both origins must be able to reach the same Redis deployment and use the - same Roomote signing keys +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. @@ -26,13 +22,19 @@ callback URLs must use HTTPS. Configure the MCP client with this server URL: ```text -/mcp +/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 add --transport http roomote --scope user /mcp claude mcp login roomote ``` 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 index a533d6cc0..4f004ec70 100644 --- 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 @@ -44,7 +44,7 @@ function authorizeRequest(options?: { 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://api.example.com/mcp'); + url.searchParams.set('resource', 'https://roomote.example/mcp'); url.searchParams.set('scope', 'mcp:roomote'); if (!options?.approved) return new NextRequest(url); @@ -119,7 +119,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { clientId, redirectUri, codeChallenge: 'a'.repeat(43), - resource: 'https://api.example.com/mcp', + resource: 'https://roomote.example/mcp', scopes: ['mcp:roomote'], }); expect(mockConsumeConsentToken).toHaveBeenCalledWith('consent-token', { 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 index 73c9101b7..939dfcb3b 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -112,7 +112,9 @@ async function handleAuthorize(request: NextRequest, approved: boolean) { ); } - const expectedResource = getRoomoteMcpResourceUrl(env.TRPC_URL); + const expectedResource = getRoomoteMcpResourceUrl( + env.R_PUBLIC_URL ?? env.R_APP_URL, + ); const scopes = (input.scope ?? ROOMOTE_MCP_SCOPE) .split(/\s+/) .filter(Boolean); 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/remote-mcp-proxy.ts b/apps/web/src/lib/server/remote-mcp-proxy.ts new file mode 100644 index 000000000..15e6a4b74 --- /dev/null +++ b/apps/web/src/lib/server/remote-mcp-proxy.ts @@ -0,0 +1,69 @@ +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 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: removeHopByHopHeaders(response.headers), + }); +} diff --git a/packages/auth/src/__tests__/mcp-access-token.test.ts b/packages/auth/src/__tests__/mcp-access-token.test.ts index 5b7064c4e..d390bc059 100644 --- a/packages/auth/src/__tests__/mcp-access-token.test.ts +++ b/packages/auth/src/__tests__/mcp-access-token.test.ts @@ -28,6 +28,9 @@ vi.mock('../client-runtime', () => ({ import { createMcpAccessToken, + getLegacyRoomoteMcpResourceUrl, + getRoomoteMcpProtectedResourceMetadataUrl, + getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE, validateMcpAccessToken, } from '../mcp-access-token'; @@ -81,4 +84,18 @@ describe('MCP access tokens', () => { 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 993064bbf..d84a85900 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -26,9 +26,11 @@ export { 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'; diff --git a/packages/auth/src/mcp-access-token.ts b/packages/auth/src/mcp-access-token.ts index 29abb8124..a6df0a718 100644 --- a/packages/auth/src/mcp-access-token.ts +++ b/packages/auth/src/mcp-access-token.ts @@ -21,14 +21,33 @@ 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 new URL(ROOMOTE_MCP_PATH, apiBaseUrl).toString(); + return appendPathToBaseUrl(apiBaseUrl, ROOMOTE_MCP_PATH); } export function getLegacyRoomoteMcpResourceUrl(apiBaseUrl: string): string { - return new URL(ROOMOTE_MCP_LEGACY_PATH, apiBaseUrl).toString(); + 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({ From 0f7635235421fed55785373fbd762040c1d1b3f6 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:12:07 +0000 Subject: [PATCH 08/15] fix: accept Claude OAuth registration metadata --- .../register/__tests__/route.test.ts | 26 ++++++++++++++++++- .../api/mcp-remote-oauth/register/route.ts | 7 ++++- 2 files changed, 31 insertions(+), 2 deletions(-) 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 index 58448472d..66f930eb0 100644 --- 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 @@ -13,7 +13,10 @@ vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ import { POST } from '../route'; -function registrationRequest(redirectUri: string) { +function registrationRequest( + redirectUri: string, + grantTypes: string[] = ['authorization_code'], +) { return new NextRequest( 'https://roomote.example/api/mcp-remote-oauth/register', { @@ -23,6 +26,7 @@ function registrationRequest(redirectUri: string) { client_name: 'Test client', redirect_uris: [redirectUri], token_endpoint_auth_method: 'none', + grant_types: grantTypes, }), }, ); @@ -58,6 +62,26 @@ describe('POST /api/mcp-remote-oauth/register', () => { ); }); + 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'], + }); + + 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'], + }); + }); + it('rejects a non-loopback HTTP callback', async () => { const response = await POST( registrationRequest('http://client.example/callback'), 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 index bdcf4d935..99da3bcd2 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/register/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -17,7 +17,12 @@ const registrationSchema = z.object({ .max(5) .refine((values) => values.every(isAllowedOAuthRedirectUri)), token_endpoint_auth_method: z.literal('none').optional(), - grant_types: z.array(z.literal('authorization_code')).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(), }); From a34952fae3bda3ec8e06a65851e4e45817a10c0b Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:21:47 +0000 Subject: [PATCH 09/15] fix: allow OAuth consent callback redirects --- .../api/mcp-remote-oauth/authorize/__tests__/route.test.ts | 3 +++ apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) 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 index 4f004ec70..a85bb50cb 100644 --- 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 @@ -93,6 +93,9 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { expect(html).toContain('Authorize Claude Code?'); expect(html).toContain('Allow access'); expect(html).toContain('name="consent_token" value="consent-token"'); + expect(response.headers.get('content-security-policy')).toContain( + "form-action 'self' https://client.example", + ); expect(mockCreateConsentToken).toHaveBeenCalledWith({ userId: 'user-1', requestTarget: expect.stringContaining( 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 index 939dfcb3b..94c147b6a 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -51,7 +51,8 @@ function consentResponse(options: { `${options.request.nextUrl.pathname}${options.request.nextUrl.search}`, ); const clientName = escapeHtml(options.clientName ?? 'An MCP client'); - const callbackHost = escapeHtml(new URL(options.redirectUri).host); + const callbackUrl = new URL(options.redirectUri); + const callbackHost = escapeHtml(callbackUrl.host); const consentToken = escapeHtml(options.consentToken); return new NextResponse( @@ -82,8 +83,7 @@ function consentResponse(options: { headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/html; charset=utf-8', - 'Content-Security-Policy': - "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + 'Content-Security-Policy': `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${callbackUrl.origin}; base-uri 'none'; frame-ancestors 'none'`, 'X-Frame-Options': 'DENY', }, }, From 78f34a0b5a8cdab5d85e41990e7be04ff3c99109 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:30:35 +0000 Subject: [PATCH 10/15] fix: return OAuth callbacks with GET --- .../app/api/mcp-remote-oauth/authorize/__tests__/route.test.ts | 1 + apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 index a85bb50cb..50eda5441 100644 --- 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 @@ -114,6 +114,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { ); 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', ); 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 index 94c147b6a..a80545cdd 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -181,7 +181,7 @@ async function handleAuthorize(request: NextRequest, approved: boolean) { const redirect = new URL(input.redirect_uri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', input.state); - return NextResponse.redirect(redirect); + return NextResponse.redirect(redirect, 303); } export function GET(request: NextRequest) { From 488e22945f94683e35e0b6d53a40571b67eb4da6 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:03:27 +0000 Subject: [PATCH 11/15] fix: keep remote MCP OAuth sessions connected --- apps/docs/integrations/remote-roomote-mcp.mdx | 5 +- .../__tests__/route.test.ts | 25 +++ .../oauth-authorization-server/route.ts | 3 +- .../register/__tests__/route.test.ts | 5 +- .../api/mcp-remote-oauth/register/route.ts | 4 +- .../revoke/__tests__/route.test.ts | 44 ++++ .../app/api/mcp-remote-oauth/revoke/route.ts | 30 +++ .../token/__tests__/route.test.ts | 135 ++++++++++- .../app/api/mcp-remote-oauth/token/route.ts | 99 ++++++-- .../src/lib/server/mcp-remote-oauth.test.ts | 121 +++++++++- apps/web/src/lib/server/mcp-remote-oauth.ts | 212 +++++++++++++++++- 11 files changed, 652 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/app/.well-known/oauth-authorization-server/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/revoke/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/mcp-remote-oauth/revoke/route.ts diff --git a/apps/docs/integrations/remote-roomote-mcp.mdx b/apps/docs/integrations/remote-roomote-mcp.mdx index f14c39252..e970e7ba9 100644 --- a/apps/docs/integrations/remote-roomote-mcp.mdx +++ b/apps/docs/integrations/remote-roomote-mcp.mdx @@ -44,8 +44,9 @@ 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 currently issues a one-hour access token without a refresh token. When -it expires, the client starts the browser authorization flow again. +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 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 index 83fec3efc..bd9e25742 100644 --- a/apps/web/src/app/.well-known/oauth-authorization-server/route.ts +++ b/apps/web/src/app/.well-known/oauth-authorization-server/route.ts @@ -17,9 +17,10 @@ export async function GET() { 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'], + grant_types_supported: ['authorization_code', 'refresh_token'], token_endpoint_auth_methods_supported: ['none'], code_challenge_methods_supported: ['S256'], scopes_supported: [ROOMOTE_MCP_SCOPE], 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 index 66f930eb0..3f93b8c77 100644 --- 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 @@ -43,6 +43,7 @@ describe('POST /api/mcp-remote-oauth/register', () => { clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', clientName: 'Test client', redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], }); const response = await POST( @@ -58,6 +59,7 @@ describe('POST /api/mcp-remote-oauth/register', () => { JSON.stringify({ clientName: 'Test client', redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], }), ); }); @@ -67,6 +69,7 @@ describe('POST /api/mcp-remote-oauth/register', () => { clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', clientName: 'Test client', redirectUris: ['http://localhost:54545/callback'], + grantTypes: ['authorization_code', 'refresh_token'], }); const response = await POST( @@ -78,7 +81,7 @@ describe('POST /api/mcp-remote-oauth/register', () => { expect(response.status).toBe(201); await expect(response.json()).resolves.toMatchObject({ - grant_types: ['authorization_code'], + grant_types: ['authorization_code', 'refresh_token'], }); }); 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 index 99da3bcd2..914cc9f98 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/register/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/register/route.ts @@ -48,6 +48,7 @@ export async function POST(request: NextRequest) { const registrationFingerprint = JSON.stringify({ clientName: parsed.data.client_name, redirectUris: parsed.data.redirect_uris, + grantTypes: parsed.data.grant_types, }); try { if (!(await isRemoteMcpRegistrationAllowed(registrationFingerprint))) { @@ -68,6 +69,7 @@ export async function POST(request: NextRequest) { client = await registerRemoteMcpOAuthClient({ clientName: parsed.data.client_name, redirectUris: parsed.data.redirect_uris, + grantTypes: parsed.data.grant_types, }); } catch { return NextResponse.json( @@ -82,7 +84,7 @@ export async function POST(request: NextRequest) { client_name: client.clientName, redirect_uris: client.redirectUris, token_endpoint_auth_method: 'none', - grant_types: ['authorization_code'], + 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 index 9f48613b8..625e3640c 100644 --- 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 @@ -1,13 +1,27 @@ import { createHash } from 'node:crypto'; import { NextRequest } from 'next/server'; -const { mockGetCode, mockConsumeCode, mockPromoteClient, mockCreateToken } = - vi.hoisted(() => ({ - mockGetCode: vi.fn(), - mockConsumeCode: vi.fn(), - mockPromoteClient: vi.fn(), - mockCreateToken: vi.fn(), - })); +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()), @@ -19,6 +33,14 @@ vi.mock('@/lib/server/mcp-remote-oauth', async (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'; @@ -45,6 +67,21 @@ function tokenRequest(overrides: Record = {}) { }); } +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(); @@ -58,7 +95,27 @@ describe('POST /api/mcp-remote-oauth/token', () => { }); 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 () => { @@ -68,6 +125,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { 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', @@ -78,6 +136,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { scopes: ['mcp:roomote'], timeoutMs: 3_600_000, }); + expect(mockBootstrapWebRuntimeEnv).toHaveBeenCalledOnce(); expect(mockConsumeCode).toHaveBeenCalledWith( 'authorization-code', expect.objectContaining({ userId: 'user-1' }), @@ -86,6 +145,68 @@ describe('POST /api/mcp-remote-oauth/token', () => { '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 () => { 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 index 3fe7c348d..441421e64 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/token/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/token/route.ts @@ -9,21 +9,35 @@ import { 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.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(), -}); +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( @@ -32,6 +46,19 @@ function oauthError(error: string) { ); } +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 { @@ -46,9 +73,47 @@ export async function POST(request: NextRequest) { } 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 || @@ -78,14 +143,14 @@ export async function POST(request: NextRequest) { 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 NextResponse.json( - { - access_token: accessToken, - token_type: 'Bearer', - expires_in: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS / 1000, - scope: ROOMOTE_MCP_SCOPE, - }, - { headers: { 'Cache-Control': 'no-store' } }, - ); + return tokenResponse(accessToken, refreshToken); } diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts index cc834fcb5..2a8cb2c22 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -14,7 +14,7 @@ vi.mock('@roomote/redis', () => ({ const keys = args.slice(0, keyCount); const values = args.slice(keyCount); const key = keys[0]!; - if (script.includes("redis.call('SET', KEYS[1]")) { + 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(); @@ -28,6 +28,63 @@ vi.mock('@roomote/redis', () => ({ return 1; } + if (script.includes('local previous =')) { + const [refreshPrefix, sessionJson, marker] = values; + const previous = redisState.get(key); + if (previous) { + const decoded = JSON.parse(previous) as { currentTokenHash: string }; + redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); + } + redisState.set(key, sessionJson!); + redisState.set(keys[1]!, marker!); + 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; @@ -94,11 +151,15 @@ import { consumeRemoteMcpConsentToken, createRemoteMcpAuthorizationCode, createRemoteMcpConsentToken, + createRemoteMcpRefreshSession, getRemoteMcpAuthorizationCode, + getRemoteMcpRefreshSession, isAllowedOAuthRedirectUri, isRemoteMcpRegistrationAllowed, promoteRemoteMcpOAuthClient, registerRemoteMcpOAuthClient, + revokeRemoteMcpRefreshSession, + rotateRemoteMcpRefreshToken, verifyPkceChallenge, } from './mcp-remote-oauth'; @@ -129,6 +190,7 @@ describe('remote MCP OAuth state', () => { expect(client).toMatchObject({ clientName: 'Test client', redirectUris: ['https://client.example/callback'], + grantTypes: ['authorization_code'], }); await expect( promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), @@ -201,6 +263,63 @@ describe('remote MCP OAuth state', () => { ); }); + 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('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) { diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index 3971114c2..2c841504e 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -4,6 +4,7 @@ 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; @@ -19,6 +20,8 @@ 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 CONSUME_CODE_LUA = ` local value = redis.call('GET', KEYS[1]) @@ -77,10 +80,65 @@ redis.call('EXPIRE', KEYS[4], ARGV[1]) return 1 `; +const CREATE_REFRESH_SESSION_LUA = ` +local previous = redis.call('GET', KEYS[1]) +if previous then + local decoded = cjson.decode(previous) + redis.call('DEL', ARGV[1] .. decoded.currentTokenHash) +end +redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[4]) +redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4]) +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 = { @@ -97,6 +155,16 @@ type RemoteMcpConsentBinding = { 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}`; } @@ -109,6 +177,35 @@ function consentKey(token: string): string { return `${CONSENT_KEY_PREFIX}${token}`; } +function refreshSessionId(userId: string, clientId: string): string { + return createHash('sha256').update(`${userId}\0${clientId}`).digest('hex'); +} + +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); @@ -128,11 +225,13 @@ export function isAllowedOAuthRedirectUri(value: string): boolean { 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); @@ -181,7 +280,17 @@ export async function getRemoteMcpOAuthClient( clientId: string, ): Promise { const value = await getRedis().get(clientKey(clientId)); - return value ? (JSON.parse(value) as RemoteMcpOAuthClient) : null; + 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( @@ -245,6 +354,107 @@ export async function consumeRemoteMcpConsentToken( return typeof value === 'string'; } +export async function createRemoteMcpRefreshSession(value: { + userId: string; + clientId: string; + resource: string; + scopes: string[]; +}): Promise { + const sessionId = refreshSessionId(value.userId, value.clientId); + 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, + 2, + refreshSessionKey(sessionId), + refreshTokenKey(tokenHash), + REFRESH_TOKEN_KEY_PREFIX, + JSON.stringify(session), + `active:${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 { From 24d65275df405e2de92c9434915cceeb95862629 Mon Sep 17 00:00:00 2001 From: "@mrubens" <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:24:36 +0000 Subject: [PATCH 12/15] fix: isolate refreshed OAuth sessions --- .../src/lib/server/mcp-remote-oauth.test.ts | 59 +++++++++++++++++-- apps/web/src/lib/server/mcp-remote-oauth.ts | 33 +++++++---- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/apps/web/src/lib/server/mcp-remote-oauth.test.ts b/apps/web/src/lib/server/mcp-remote-oauth.test.ts index 2a8cb2c22..11efd77df 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -28,15 +28,24 @@ vi.mock('@roomote/redis', () => ({ return 1; } - if (script.includes('local previous =')) { - const [refreshPrefix, sessionJson, marker] = values; - const previous = redisState.get(key); - if (previous) { - const decoded = JSON.parse(previous) as { currentTokenHash: string }; - redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); + 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; } @@ -288,6 +297,44 @@ describe('remote MCP OAuth state', () => { ).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', diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index 2c841504e..ee168b688 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -22,6 +22,7 @@ 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]) @@ -81,13 +82,19 @@ return 1 `; const CREATE_REFRESH_SESSION_LUA = ` -local previous = redis.call('GET', KEYS[1]) -if previous then - local decoded = cjson.decode(previous) - redis.call('DEL', ARGV[1] .. decoded.currentTokenHash) +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[2], 'EX', ARGV[4]) -redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4]) +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 `; @@ -177,8 +184,11 @@ function consentKey(token: string): string { return `${CONSENT_KEY_PREFIX}${token}`; } -function refreshSessionId(userId: string, clientId: string): string { - return createHash('sha256').update(`${userId}\0${clientId}`).digest('hex'); +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 { @@ -360,7 +370,7 @@ export async function createRemoteMcpRefreshSession(value: { resource: string; scopes: string[]; }): Promise { - const sessionId = refreshSessionId(value.userId, value.clientId); + const sessionId = randomBytes(32).toString('hex'); const refreshToken = createRefreshToken(sessionId); const tokenHash = refreshTokenHash(refreshToken); const now = Math.floor(Date.now() / 1000); @@ -372,12 +382,15 @@ export async function createRemoteMcpRefreshSession(value: { }; await getRedis().eval( CREATE_REFRESH_SESSION_LUA, - 2, + 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; From faab362a22e74d15d642af6ed5e88fd1b7220679 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:59:57 -0400 Subject: [PATCH 13/15] Fix compressed remote MCP responses --- .../src/lib/server/remote-mcp-proxy.test.ts | 22 +++++++++++++++++++ apps/web/src/lib/server/remote-mcp-proxy.ts | 13 ++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/server/remote-mcp-proxy.test.ts 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 index 15e6a4b74..8ed1a7626 100644 --- a/apps/web/src/lib/server/remote-mcp-proxy.ts +++ b/apps/web/src/lib/server/remote-mcp-proxy.ts @@ -35,6 +35,17 @@ function sanitizeRequestHeaders(headers: Headers): Headers { 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', @@ -64,6 +75,6 @@ export async function proxyRemoteMcpRequest( return new NextResponse(response.body, { status: response.status, statusText: response.statusText, - headers: removeHopByHopHeaders(response.headers), + headers: sanitizeProxiedResponseHeaders(response.headers), }); } From a48868c6b871b064020d196284c50e49e55b3a0a Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:40:09 -0400 Subject: [PATCH 14/15] Restyle MCP OAuth consent screen --- .../authorize/__tests__/route.test.ts | 8 + .../api/mcp-remote-oauth/authorize/route.ts | 201 ++++++++++++++++-- 2 files changed, 195 insertions(+), 14 deletions(-) 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 index 50eda5441..6eb423d2e 100644 --- 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 @@ -92,10 +92,18 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { 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( 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 index a80545cdd..b63cbd044 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/authorize/route.ts @@ -62,20 +62,193 @@ function consentResponse(options: { Authorize ${clientName} + - -
-
-

Roomote MCP

-

Authorize ${clientName}?

-

This client will act as your signed-in Roomote member. It can read task and chat context, launch or cancel tasks, and send follow-up messages.

-
After approval, Roomote returns you to ${callbackHost}.
-
- - -
-
-
+ +
+
+
+ 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.

+
+
+
+
`, { @@ -83,7 +256,7 @@ function consentResponse(options: { headers: { 'Cache-Control': 'no-store', 'Content-Type': 'text/html; charset=utf-8', - 'Content-Security-Policy': `default-src 'none'; style-src 'unsafe-inline'; form-action 'self' ${callbackUrl.origin}; base-uri 'none'; frame-ancestors 'none'`, + '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', }, }, From b2b34b12f0adfddeea63eabf51c0843f89386020 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:44:25 -0400 Subject: [PATCH 15/15] Rename Roomote MCP documentation --- apps/docs/docs.json | 2 +- apps/docs/integrations/index.mdx | 7 ++++--- .../{remote-roomote-mcp.mdx => roomote-mcp.mdx} | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) rename apps/docs/integrations/{remote-roomote-mcp.mdx => roomote-mcp.mdx} (99%) diff --git a/apps/docs/docs.json b/apps/docs/docs.json index c1aa967df..b226421a3 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -135,7 +135,7 @@ "pages": [ "integrations/index", "integrations/custom-mcp-servers", - "integrations/remote-roomote-mcp", + "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 1d494a76a..f143a04ff 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -90,9 +90,10 @@ 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 -[Remote Roomote MCP](/integrations/remote-roomote-mcp). Its browser-issued -credential is limited to Roomote's shared context tools and does not grant -general API access. +[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 diff --git a/apps/docs/integrations/remote-roomote-mcp.mdx b/apps/docs/integrations/roomote-mcp.mdx similarity index 99% rename from apps/docs/integrations/remote-roomote-mcp.mdx rename to apps/docs/integrations/roomote-mcp.mdx index e970e7ba9..69f211c2c 100644 --- a/apps/docs/integrations/remote-roomote-mcp.mdx +++ b/apps/docs/integrations/roomote-mcp.mdx @@ -1,5 +1,5 @@ --- -title: Remote Roomote MCP +title: Roomote MCP icon: plug-circle-bolt description: Connect an OAuth-capable MCP client to Roomote's member task tools. ---