diff --git a/.changeset/inbound-mcp-authorizations.md b/.changeset/inbound-mcp-authorizations.md new file mode 100644 index 0000000000..b76a860041 --- /dev/null +++ b/.changeset/inbound-mcp-authorizations.md @@ -0,0 +1,7 @@ +--- +'@roomote/web': major +--- + +Manage inbound MCP client authorizations from personal settings and, for admins, by user in users settings, including revoking all of a user's clients. Revocation now blocks already-issued OAuth access tokens on subsequent requests. + +**Breaking upgrade compatibility:** legacy already-issued MCP access tokens without client/grant identity are rejected with 401 once the new API enforcement is deployed. Deploy the new web token issuer and drain all old issuer instances **before** deploying API enforcement. Live legacy refresh sessions are automatically backfilled, and existing refresh tokens can obtain tracked access tokens while preserving the original remaining session lifetime. Clients without a usable refresh session, or unable to recover by refreshing after a 401, must reconnect and repeat browser consent. In-progress legacy authorization codes must repeat consent too. Redis authorization data must remain available; missing data cannot be backfilled. Rolling back the API removes prompt revocation enforcement, and rolling back the issuer may require reconnecting migrated clients. diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 2d443ac0dd..26335b3c42 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -181,6 +181,9 @@ jobs: run: pnpm --filter @roomote/db db:push:test --force env: DATABASE_URL: postgres://postgres:password@localhost:5432/test + - name: Install isolated OAuth lifecycle test dependency + # Disposable Unix-socket servers keep Lua lifecycle tests isolated. + run: command -v redis-server || (sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y redis-server) - name: Run unit tests run: pnpm test:ci env: diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index c0fe272026..b05710d10d 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -21,6 +21,12 @@ vi.mock('@roomote/redis', async (importOriginal) => { return { ...actual, + // This suite injects verified credentials rather than exercising token + // storage; real grant lifecycle enforcement is covered by HTTP access tests. + isMcpOAuthGrantLive: vi.fn( + async (auth) => + auth.clientId === 'fixture-client' && auth.grantId === 'fixture-grant', + ), getRedis: () => ({ // In-memory stand-in for the atomic INCR+EXPIRE rate limit script. eval: async (_script: string, _numKeys: number, key: string) => { @@ -103,6 +109,8 @@ vi.mock('../middleware', async (importOriginal) => { c.set('authContext', { tokenType: 'mcp', userId: mcpAuthState.userId, + clientId: 'fixture-client', + grantId: 'fixture-grant', resource: getRoomoteMcpResourceUrl( Env.R_PUBLIC_URL ?? Env.R_APP_URL, ), @@ -116,6 +124,8 @@ vi.mock('../middleware', async (importOriginal) => { tokenType: 'mcp', userId: 'user-123', resource: 'https://wrong.example/mcp', + clientId: 'fixture-client', + grantId: 'fixture-grant', scopes: ['mcp:roomote'], version: 1, } as Variables['authContext']); @@ -126,6 +136,8 @@ vi.mock('../middleware', async (importOriginal) => { tokenType: 'mcp', userId: 'user-123', resource: getLegacyRoomoteMcpResourceUrl(Env.TRPC_URL), + clientId: 'fixture-client', + grantId: 'fixture-grant', scopes: ['mcp:roomote'], version: 1, } as Variables['authContext']); diff --git a/apps/api/src/handlers/mcp/__tests__/models.access.test.ts b/apps/api/src/handlers/mcp/__tests__/models.access.test.ts index cfc75122c2..a5cb0f77ed 100644 --- a/apps/api/src/handlers/mcp/__tests__/models.access.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/models.access.test.ts @@ -19,6 +19,8 @@ import { import { Env } from '@roomote/env'; import type { TaskModelOption } from '@roomote/types'; import { createApiApp } from '../../../server'; +import { oauthGrantFixture } from './oauth-fixture'; +import { revokeMcpOAuthGrants } from '@roomote/redis'; describe('model discovery through authenticated HTTP', () => { beforeAll(() => { @@ -88,6 +90,7 @@ describe('model discovery through authenticated HTTP', () => { Env.R_PUBLIC_URL ?? Env.R_APP_URL, ); const token = await createMcpAccessToken({ + ...(await oauthGrantFixture(member.id, resource)), userId: member.id, resource, scopes: [ROOMOTE_MCP_SCOPE], @@ -138,6 +141,7 @@ describe('model discovery through authenticated HTTP', () => { expect((await get('/mcp/models', otherToken)).body).toEqual(all.body); expect((await get('/mcp/models', null)).response.status).toBe(401); const wrong = await createMcpAccessToken({ + ...(await oauthGrantFixture(member.id, resource)), userId: member.id, resource: 'https://wrong.example/mcp', scopes: [ROOMOTE_MCP_SCOPE], @@ -173,6 +177,33 @@ describe('model discovery through authenticated HTTP', () => { expect(text).toContain(models[1]!.id); expect(text).not.toContain('Unknown action'); expect(text).not.toContain('"isError":true'); + await revokeMcpOAuthGrants(member.id); + expect((await get('/mcp/models')).response.status).toBe(401); + expect((await get('/mcp/task-outputs/download')).response.status).toBe( + 401, + ); + const revokedMcp = await fetch(`${base}/mcp`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }, + body: JSON.stringify(rpc), + }); + expect(revokedMcp.status).toBe(401); + const reconnected = await createMcpAccessToken({ + ...(await oauthGrantFixture(member.id, resource)), + userId: member.id, + resource, + scopes: [ROOMOTE_MCP_SCOPE], + timeoutMs: 60000, + }); + expect((await get('/mcp/models', reconnected)).response.status).toBe(200); + expect((await get('/mcp/models')).response.status).toBe(401); + expect((await get('/mcp/task-outputs/download')).response.status).toBe( + 401, + ); receipts.push({ method: 'POST', path: '/mcp', diff --git a/apps/api/src/handlers/mcp/__tests__/models.test.ts b/apps/api/src/handlers/mcp/__tests__/models.test.ts index 669677707a..40c3a22962 100644 --- a/apps/api/src/handlers/mcp/__tests__/models.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/models.test.ts @@ -5,7 +5,11 @@ import type { TaskModelOption } from '@roomote/types'; import type { Variables } from '../../../types'; import { listMemberModels } from '../models'; -const { options } = vi.hoisted(() => ({ options: vi.fn() })); +const { options, grantLive } = vi.hoisted(() => ({ + options: vi.fn(), + grantLive: vi.fn(), +})); +vi.mock('@roomote/redis', () => ({ isMcpOAuthGrantLive: grantLive })); vi.mock('@roomote/db/server', async (original) => ({ ...(await original()), getDeploymentTaskModelOptions: options, @@ -33,7 +37,16 @@ function app( } describe('member model discovery', () => { - beforeEach(() => options.mockReset()); + beforeEach(() => { + options.mockReset(); + grantLive + .mockReset() + .mockImplementation( + async (auth) => + auth.clientId === 'fixture-client' && + auth.grantId === 'fixture-grant', + ); + }); it('bounds large catalogs and traverses IDs without gaps or duplicates', async () => { const models = Array.from({ length: 235 }, (_, i) => @@ -133,6 +146,8 @@ describe('member model discovery', () => { tokenType: 'mcp' as const, version: 1, userId: 'member', + clientId: 'fixture-client', + grantId: 'fixture-grant', resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), scopes: [ROOMOTE_MCP_SCOPE], }; @@ -158,4 +173,24 @@ describe('member model discovery', () => { options.mockResolvedValue({ models: [], defaultModelId: 'openai/a' }); expect((await app(mcp).request('/models')).status).toBe(200); }); + + it.each(['revoked', 'redis unavailable'])( + 'refuses OAuth catalog reads when %s', + async (reason) => { + if (reason === 'revoked') grantLive.mockResolvedValue(false); + else grantLive.mockRejectedValue(new Error('Redis unavailable')); + const response = await app({ + userId: 'member', + tokenType: 'mcp', + version: 1, + clientId: 'fixture-client', + grantId: 'fixture-grant', + resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), + scopes: [ROOMOTE_MCP_SCOPE], + }).request('/models'); + expect(response.status).toBe(401); + expect(grantLive).toHaveBeenCalledOnce(); + expect(options).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/api/src/handlers/mcp/__tests__/oauth-fixture.ts b/apps/api/src/handlers/mcp/__tests__/oauth-fixture.ts new file mode 100644 index 0000000000..cac5a962c6 --- /dev/null +++ b/apps/api/src/handlers/mcp/__tests__/oauth-fixture.ts @@ -0,0 +1,28 @@ +import { randomUUID } from 'node:crypto'; +import { + createMcpOAuthCode, + getMcpOAuthCode, + exchangeMcpOAuthCode, + revokeMcpOAuthGrants, +} from '@roomote/redis'; + +const fixtureUsers = new Set(); +afterAll(async () => { + for (const userId of fixtureUsers) await revokeMcpOAuthGrants(userId); +}); + +export async function oauthGrantFixture(userId: string, resource: string) { + fixtureUsers.add(userId); + const clientId = randomUUID(); + const code = await createMcpOAuthCode({ + userId, + clientId, + resource, + scopes: ['mcp:roomote'], + redirectUri: 'https://client.example/callback', + codeChallenge: 'fixture', + }); + const grant = (await getMcpOAuthCode(code))!; + await exchangeMcpOAuthCode(code, grant, true); + return { clientId, grantId: grant.grantId }; +} diff --git a/apps/api/src/handlers/mcp/__tests__/task-outputs.test.ts b/apps/api/src/handlers/mcp/__tests__/task-outputs.test.ts index 9a5f76f6d8..dd319cf437 100644 --- a/apps/api/src/handlers/mcp/__tests__/task-outputs.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/task-outputs.test.ts @@ -1,6 +1,8 @@ import type { Context, Next } from 'hono'; import { getRoomoteMcpResourceUrl } from '@roomote/auth'; import { Env } from '@roomote/env'; +import { revokeMcpOAuthGrants } from '@roomote/redis'; +import { oauthGrantFixture } from './oauth-fixture'; import { automations, customAutomations, @@ -47,6 +49,10 @@ import { createApiApp } from '../../../server'; const createdTasks: string[] = []; const createdUsers: string[] = []; const createdAutomations: string[] = []; +const oauthIdentities = new Map< + string, + Awaited> +>(); let owner: Awaited>; let other: typeof owner; let admin: typeof owner; @@ -68,6 +74,7 @@ function credential(userId: string, kind: 'oauth' | 'user' = 'oauth') { kind === 'oauth' ? { userId, + ...oauthIdentities.get(userId), tokenType: 'mcp', resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), scopes: ['mcp:roomote'], @@ -157,6 +164,7 @@ async function receipt( } beforeEach(async () => { + oauthIdentities.clear(); state.credentials.clear(); state.object.mockReset(); state.object.mockImplementation(async () => ({ @@ -174,6 +182,17 @@ beforeEach(async () => { other = await userFactory.create({ role: 'member' }); admin = await userFactory.create({ role: 'admin' }); createdUsers.push(owner.id, other.id, admin.id); + await Promise.all( + [owner, other, admin].map(async (user) => { + oauthIdentities.set( + user.id, + await oauthGrantFixture( + user.id, + getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), + ), + ); + }), + ); task = await createTask(); run = await runFactory.create({ taskId: task.id, @@ -182,6 +201,7 @@ beforeEach(async () => { }); afterEach(async () => { + await Promise.all(createdUsers.map((userId) => revokeMcpOAuthGrants(userId))); if (createdTasks.length) await db.delete(tasks).where(inArray(tasks.id, createdTasks)); if (createdAutomations.length) @@ -195,6 +215,29 @@ afterEach(async () => { createdAutomations.length = 0; }); +it('rejects previously issued download access immediately after its Redis grant is revoked', async () => { + const file = await artifact(); + const authorization = credential(owner.id); + const issued = await call( + { action: 'get_artifact_download_url', taskId: task.id, path: file.path }, + authorization, + ); + const url = new URL(issued.data.url); + const path = `${url.pathname}${url.search}`; + expect( + (await createApiApp().request(path, { headers: { authorization } })).status, + ).toBe(200); + await revokeMcpOAuthGrants(owner.id); + // Keep the injected verified auth context: live Redis revocation, rather than + // removing a token from the test's authentication map, must reject the GET. + expect(state.credentials.has(authorization)).toBe(true); + state.object.mockClear(); + expect( + (await createApiApp().request(path, { headers: { authorization } })).status, + ).toBe(401); + expect(state.object).not.toHaveBeenCalled(); +}); + it.each(['oauth', 'user'] as const)( 'lists only latest uploaded paths with %s public credentials', async (kind) => { diff --git a/apps/api/src/handlers/mcp/http-integrations/auth.test.ts b/apps/api/src/handlers/mcp/http-integrations/auth.test.ts index b123067f70..cf5780f89e 100644 --- a/apps/api/src/handlers/mcp/http-integrations/auth.test.ts +++ b/apps/api/src/handlers/mcp/http-integrations/auth.test.ts @@ -1,6 +1,7 @@ import { generateKeyPairSync, randomUUID } from 'node:crypto'; import { Hono } from 'hono'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { oauthGrantFixture } from '../__tests__/oauth-fixture'; import { configureAuthClientEnv, createAuthToken, @@ -503,6 +504,7 @@ it.each(['unknown', 'deleted'] as const)( it('rejects a real public MCP token for an active member at the route policy', async () => { const actor = await member(); const token = await createMcpAccessToken({ + ...(await oauthGrantFixture(actor.id, 'http://localhost:3000/mcp')), userId: actor.id, resource: 'http://localhost:3000/mcp', scopes: ['mcp:roomote'], diff --git a/apps/api/src/handlers/mcp/models.ts b/apps/api/src/handlers/mcp/models.ts index 331d771de1..4b6a5b35c2 100644 --- a/apps/api/src/handlers/mcp/models.ts +++ b/apps/api/src/handlers/mcp/models.ts @@ -18,7 +18,7 @@ const cursorSchema = z export async function listMemberModels(c: Context<{ Variables: Variables }>) { c.header('Cache-Control', 'no-store, private'); try { - resolveRoomoteMemberAuth(c.get('authContext')); + await resolveRoomoteMemberAuth(c.get('authContext')); } catch (error) { if (error instanceof McpProxyError) return Response.json( diff --git a/apps/api/src/handlers/mcp/roomote-member-auth.ts b/apps/api/src/handlers/mcp/roomote-member-auth.ts index a6c1a6065b..f8e60c45ca 100644 --- a/apps/api/src/handlers/mcp/roomote-member-auth.ts +++ b/apps/api/src/handlers/mcp/roomote-member-auth.ts @@ -1,5 +1,6 @@ import { getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE } from '@roomote/auth'; import { Env } from '@roomote/env'; +import { isMcpOAuthGrantLive } from '@roomote/redis'; import { isUserToken, PRODUCT_NAME } from '@roomote/types'; import type { Variables } from '../../types'; @@ -7,9 +8,9 @@ import type { McpAuth } from './middleware'; import { McpProxyError } from './proxy-utils'; /** Public member reads and downloads share the /mcp credential boundary. */ -export function resolveRoomoteMemberAuth( +export async function resolveRoomoteMemberAuth( auth: Variables['authContext'], -): McpAuth & { userId: string } { +): Promise { if (!auth) throw new McpProxyError(401, 'Authentication required'); if (auth.tokenType === 'run') { throw new McpProxyError( @@ -24,6 +25,17 @@ export function resolveRoomoteMemberAuth( getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL) && auth.scopes.includes(ROOMOTE_MCP_SCOPE) ) { + let live = false; + try { + live = await isMcpOAuthGrantLive(auth); + } catch { + /* Fail closed. */ + } + if (!live) + throw new McpProxyError( + 401, + 'MCP authorization expired or revoked; reconnect your client', + ); return { userId: auth.userId, authContext: { diff --git a/apps/api/src/handlers/mcp/roomote.ts b/apps/api/src/handlers/mcp/roomote.ts index 19f30095fe..6947567c2c 100644 --- a/apps/api/src/handlers/mcp/roomote.ts +++ b/apps/api/src/handlers/mcp/roomote.ts @@ -106,7 +106,7 @@ async function resolveRoomoteMcpAuth( options: { allowLegacyAudience: boolean }, ): Promise { if (!options.allowLegacyAudience) { - const member = resolveRoomoteMemberAuth(authContext); + const member = await resolveRoomoteMemberAuth(authContext); return { userId: member.userId, tokenType: 'auth' }; } if (!authContext) { @@ -149,6 +149,10 @@ async function resolveRoomoteMcpAuth( ].includes(authContext.resource) && authContext.scopes.includes(ROOMOTE_MCP_SCOPE) ) { + await resolveRoomoteMemberAuth({ + ...authContext, + resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), + }); return { userId: authContext.userId, tokenType: 'auth', diff --git a/apps/api/src/handlers/mcp/task-outputs.ts b/apps/api/src/handlers/mcp/task-outputs.ts index 02ef5963ae..5370c14480 100644 --- a/apps/api/src/handlers/mcp/task-outputs.ts +++ b/apps/api/src/handlers/mcp/task-outputs.ts @@ -38,7 +38,7 @@ import { resolveRoomoteMemberAuth } from './roomote-member-auth'; type MemberContext = Context<{ Variables: Variables & { mcpAuth: McpAuth } }>; async function requireReadableTask(taskId: string, auth: McpAuth) { - const member = resolveRoomoteMemberAuth(auth.authContext); + const member = await resolveRoomoteMemberAuth(auth.authContext); const task = await db.query.tasks.findFirst({ columns: { id: true }, where: and( @@ -234,7 +234,7 @@ export async function downloadMemberTaskArtifact( ): Promise { c.header('Cache-Control', 'private, no-store'); try { - const auth = resolveRoomoteMemberAuth(c.get('authContext')); + const auth = await resolveRoomoteMemberAuth(c.get('authContext')); const input = z .object({ taskId: z.string().regex(/^[0-9a-z]{13}$/), diff --git a/apps/api/src/handlers/sessions/model-selection.access.test.ts b/apps/api/src/handlers/sessions/model-selection.access.test.ts index 0514a60e3e..a92e01d0d2 100644 --- a/apps/api/src/handlers/sessions/model-selection.access.test.ts +++ b/apps/api/src/handlers/sessions/model-selection.access.test.ts @@ -2,6 +2,7 @@ import { generateKeyPairSync } from 'node:crypto'; import { once } from 'node:events'; import { writeFile } from 'node:fs/promises'; import { serve } from '@hono/node-server'; +import { oauthGrantFixture } from '../mcp/__tests__/oauth-fixture'; import { configureAuthClientEnv, createAuthToken, @@ -111,6 +112,10 @@ describe('configured session model API', () => { const base = `http://127.0.0.1:${address.port}`; const token = await createAuthToken({ userId: user.id, timeoutMs: 60000 }); const oauth = await createMcpAccessToken({ + ...(await oauthGrantFixture( + user.id, + getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), + )), userId: user.id, resource: getRoomoteMcpResourceUrl(Env.R_PUBLIC_URL ?? Env.R_APP_URL), scopes: [ROOMOTE_MCP_SCOPE], diff --git a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts index f04e3a7848..4471af16f4 100644 --- a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts +++ b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts @@ -8,13 +8,16 @@ const { mockValidateAuthToken, mockFindDeployment, mockFindUser, + mockGrantLive, } = vi.hoisted(() => ({ mockValidateRunToken: vi.fn(), mockValidateMcpAccessToken: vi.fn(), mockValidateAuthToken: vi.fn(), mockFindDeployment: vi.fn(), mockFindUser: vi.fn(), + mockGrantLive: vi.fn(), })); +vi.mock('@roomote/redis', () => ({ isMcpOAuthGrantLive: mockGrantLive })); vi.mock('@roomote/auth', () => ({ validateRunToken: mockValidateRunToken, @@ -79,6 +82,7 @@ describe('tokenAuthMiddleware token extraction', () => { vi.clearAllMocks(); mockFindDeployment.mockResolvedValue({ metadata: null }); mockFindUser.mockResolvedValue({ id: 'user-1', deletedAt: null }); + mockGrantLive.mockResolvedValue(true); mockValidateRunToken.mockImplementation(async (token: string) => { if (token !== 'valid-run-token') { throw new Error('invalid token'); @@ -165,6 +169,25 @@ describe('tokenAuthMiddleware token extraction', () => { expect(mockValidateAuthToken).not.toHaveBeenCalled(); }); + it.each(['revoked', 'redis unavailable'])( + 'fails closed for OAuth when %s', + async (reason) => { + mockValidateMcpAccessToken.mockResolvedValue({ + tokenType: 'mcp', + userId: 'user-1', + clientId: 'client', + grantId: 'grant', + }); + if (reason === 'revoked') mockGrantLive.mockResolvedValue(false); + else mockGrantLive.mockRejectedValue(new Error('Redis unavailable')); + expect( + await requestAuthContext('/mcp/models', { + authorization: 'Bearer oauth', + }), + ).toBeNull(); + }, + ); + 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/tokenAuthMiddleware.ts b/apps/api/src/middleware/tokenAuthMiddleware.ts index d202845865..b5a3fc39db 100644 --- a/apps/api/src/middleware/tokenAuthMiddleware.ts +++ b/apps/api/src/middleware/tokenAuthMiddleware.ts @@ -8,6 +8,7 @@ import { validateSessionBrokerToken, } from '@roomote/auth'; import { db, deploymentSettings, eq, users } from '@roomote/db/server'; +import { isMcpOAuthGrantLive } from '@roomote/redis'; import { CREDENTIAL_EGRESS_PROXY_PATH, isRoomoteDeploymentDisabled, @@ -123,7 +124,8 @@ export const tokenAuthMiddleware = () => | undefined; try { - userScopedAuth = await validateMcpAccessToken(token); + const oauth = await validateMcpAccessToken(token); + if (await isMcpOAuthGrantLive(oauth)) userScopedAuth = oauth; } catch { // Not an MCP OAuth token, try the internal user auth token below. } diff --git a/apps/docs/integrations/roomote-mcp.mdx b/apps/docs/integrations/roomote-mcp.mdx index 1ee7f567e0..fed7426cb2 100644 --- a/apps/docs/integrations/roomote-mcp.mdx +++ b/apps/docs/integrations/roomote-mcp.mdx @@ -362,8 +362,25 @@ use the task and context tools described above, but it does not provide general Roomote API access or administrator privileges. Existing Roomote permissions and connected-service access checks still apply to every request. -You can revoke the connection from your MCP client. If a saved session expires -or is revoked, Roomote asks you to approve access again in the browser. +### Manage connected clients + +Open **Settings → Personal → Connected MCP clients** to see and revoke your +authorizations. Admins can open **Settings → Users**, choose an **MCP +authorization owner**, and revoke one client or **Revoke all** for that user. +These controls affect inbound access to Roomote, separately from outbound +integrations. Each user’s authorization is independent, even when multiple users +connect through the same registered client. + +The list includes clients awaiting their first token exchange. It shows expiry, +last authenticated request, and last token exchange separately. Historical +timestamps that were not recorded before an upgrade appear as **Not recorded**. +Client names are supplied by the client, not verified publisher identities. + +Revocation stops subsequent authenticated requests, including already-issued +access tokens; work already accepted can finish. Reconnecting requires browser +consent and creates a fresh authorization. Old tokens remain invalid after +reconnecting. You can also revoke from your MCP client using the OAuth revocation +endpoint. Revocation preserves the reusable client registration. Access tokens last one hour. Clients that request refresh tokens can refresh access within a 30-day session; rotating a refresh token does not extend that @@ -372,6 +389,38 @@ code exchange or token refresh keeps the same client ID available for another 90 days. Token expiration or revocation does not itself delete that registration or let a revoked token regain access. +### Upgrading an existing deployment + +Existing connections with a live refresh session can be preserved. Roomote +automatically backfills those sessions when they refresh or when their owner’s +client list is opened. This preserves the original remaining expiry; it does not +grant another 30 days. No database migration or Redis commands are required. + +Previously issued access tokens do not identify their client authorization, so +they cannot be safely backfilled or selectively revoked. After the enforcing API +upgrade, those tokens receive **401 Unauthorized**. A refresh-capable client can +use its existing refresh token to obtain a tracked access token and continue. If +the client does not refresh after a 401, reconnect it once. Clients without a +refresh token, expired sessions, and in-progress authorization codes from the old +version must repeat browser consent. A missing or expired client registration +requires removing and re-adding the connection. + +For a staged self-hosted rollout, deploy the updated **web authorization server +first**, drain all old web instances, then deploy the updated **API**. The previous +API accepts the additional token claims but does not enforce live revocation; +prompt revocation is guaranteed only after every API instance is updated. Do not +continue running an old authorization server after enabling enforcement: it can +only issue untracked access tokens. A coordinated deployment is also supported, +but clients may need to refresh once when the API changes. + +OAuth access requires available Redis on every authenticated request. During an +outage access is refused rather than trusting a potentially revoked token; retry +after Redis recovers. Losing Redis authorization data requires reconnecting. +Backfill cannot recover expired or lost sessions. Reverting the API removes +prompt-revocation enforcement; reverting the authorization server can require +reconnecting already-migrated clients because their legacy refresh state was +replaced. Keep web and API versions coordinated during rollback too. + To limit unused registrations, initial registration must be followed by a successful authorization code exchange within one hour. After 90 days without a successful exchange or refresh, an activated registration expires. If that 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 8cdb67c475..269698e424 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 @@ -23,10 +23,10 @@ vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ })); vi.mock('@/lib/server/mcp-remote-oauth', () => ({ getRemoteMcpOAuthClient: mockGetClient, - createRemoteMcpAuthorizationCode: mockCreateCode, createRemoteMcpConsentToken: mockCreateConsentToken, consumeRemoteMcpConsentToken: mockConsumeConsentToken, })); +vi.mock('@roomote/redis', () => ({ createMcpOAuthCode: mockCreateCode })); import { GET, POST } from '../route'; @@ -133,14 +133,17 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { expect(location.toString()).toBe( 'https://client.example/callback?code=authorization-code&state=client-state', ); - expect(mockCreateCode).toHaveBeenCalledWith({ - userId: 'user-1', - clientId, - redirectUri, - codeChallenge: 'a'.repeat(43), - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); + expect(mockCreateCode).toHaveBeenCalledWith( + { + userId: 'user-1', + clientId, + redirectUri, + codeChallenge: 'a'.repeat(43), + resource: 'https://roomote.example/mcp', + scopes: ['mcp:roomote'], + }, + 'Claude Code', + ); expect(mockConsumeConsentToken).toHaveBeenCalledWith('consent-token', { userId: 'user-1', requestTarget: expect.stringContaining( @@ -185,6 +188,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { ); expect(mockCreateCode).toHaveBeenCalledWith( expect.objectContaining({ redirectUri: codexRedirectUri }), + 'Codex', ); }); @@ -224,6 +228,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { ); expect(mockCreateCode).toHaveBeenCalledWith( expect.objectContaining({ redirectUri: cursorRedirectUri }), + 'Cursor', ); }); @@ -263,6 +268,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { ); expect(mockCreateCode).toHaveBeenCalledWith( expect.objectContaining({ redirectUri: claudeRedirectUri }), + 'Claude Code', ); }); @@ -301,6 +307,7 @@ describe('GET /api/mcp-remote-oauth/authorize', () => { expect(response.status).toBe(303); expect(mockCreateCode).toHaveBeenCalledWith( expect.objectContaining({ resource: 'https://roomote.example/mcp' }), + 'Claude Code', ); }); 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 8a1364fe56..3aa21b8853 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 @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getRoomoteMcpResourceUrl, ROOMOTE_MCP_SCOPE } from '@roomote/auth'; +import { createMcpOAuthCode } from '@roomote/redis'; import { authorize } from '@/lib/server'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; import { getPublicAppUrl } from '@/lib/server/get-public-app-url'; import { - createRemoteMcpAuthorizationCode, createRemoteMcpConsentToken, consumeRemoteMcpConsentToken, getRemoteMcpOAuthClient, @@ -348,14 +348,17 @@ async function handleAuthorize(request: NextRequest, approved: boolean) { ); } - const code = await createRemoteMcpAuthorizationCode({ - userId: auth.userId, - clientId: input.client_id, - redirectUri: input.redirect_uri, - codeChallenge: input.code_challenge, - resource, - scopes, - }); + const code = await createMcpOAuthCode( + { + userId: auth.userId, + clientId: input.client_id, + redirectUri: input.redirect_uri, + codeChallenge: input.code_challenge, + resource, + scopes, + }, + client.clientName, + ); const redirect = new URL(input.redirect_uri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', input.state); 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 index fd3571ca6e..d554e92c2e 100644 --- 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 @@ -2,8 +2,12 @@ import { NextRequest } from 'next/server'; const mockRevokeRefreshSession = vi.hoisted(() => vi.fn()); -vi.mock('@/lib/server/mcp-remote-oauth', () => ({ - revokeRemoteMcpRefreshSession: mockRevokeRefreshSession, +vi.mock('@roomote/redis', () => ({ + getMcpOAuthRefresh: vi.fn(), + revokeMcpOAuthRefresh: mockRevokeRefreshSession, +})); +vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ + bootstrapWebRuntimeEnv: vi.fn(), })); import { POST } from '../route'; 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 index fe4a9f8b89..a50f187966 100644 --- a/apps/web/src/app/api/mcp-remote-oauth/revoke/route.ts +++ b/apps/web/src/app/api/mcp-remote-oauth/revoke/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { revokeRemoteMcpRefreshSession } from '@/lib/server/mcp-remote-oauth'; +import { getMcpOAuthRefresh, revokeMcpOAuthRefresh } from '@roomote/redis'; +import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; export const runtime = 'nodejs'; @@ -22,7 +23,10 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); } - await revokeRemoteMcpRefreshSession(parsed.data.token, parsed.data.client_id); + await bootstrapWebRuntimeEnv(); + // Adopt a legacy refresh family before revoking it through the same lifecycle. + await getMcpOAuthRefresh(parsed.data.token); + await revokeMcpOAuthRefresh(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 805097fab2..67ec98057b 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 @@ -34,16 +34,28 @@ vi.mock('@roomote/auth', async (importOriginal) => ({ vi.mock('@/lib/server/mcp-remote-oauth', async (importOriginal) => ({ ...(await importOriginal()), - getRemoteMcpAuthorizationCode: mockGetCode, - consumeRemoteMcpAuthorizationCode: mockConsumeCode, promoteRemoteMcpOAuthClient: mockPromoteClient, - createRemoteMcpRefreshSession: mockCreateRefreshSession, - getRemoteMcpRefreshSession: mockGetRefreshSession, - revokeRemoteMcpRefreshSessionOnReplay: mockRevokeOnReplay, - rotateRemoteMcpRefreshToken: mockRotateRefreshToken, getRemoteMcpOAuthClient: mockGetClient, })); +vi.mock('@roomote/redis', () => ({ + getMcpOAuthCode: mockGetCode, + exchangeMcpOAuthCode: async ( + code: string, + grant: unknown, + refresh: boolean, + ) => { + const result = await mockConsumeCode(code, grant); + if (!result) return null; + return { + refreshToken: refresh ? await mockCreateRefreshSession(grant) : undefined, + }; + }, + getMcpOAuthRefresh: mockGetRefreshSession, + revokeMcpOAuthRefresh: mockRevokeOnReplay, + rotateMcpOAuthRefresh: mockRotateRefreshToken, +})); + vi.mock('@/lib/server/bootstrap-runtime-env', () => ({ bootstrapWebRuntimeEnv: mockBootstrapWebRuntimeEnv, })); @@ -105,6 +117,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { beforeEach(() => { vi.clearAllMocks(); mockGetCode.mockResolvedValue({ + grantId: 'grant', userId: 'user-1', clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', redirectUri: 'https://client.example/callback', @@ -116,6 +129,7 @@ describe('POST /api/mcp-remote-oauth/token', () => { mockPromoteClient.mockResolvedValue(true); mockCreateRefreshSession.mockResolvedValue('initial-refresh-token'); mockGetRefreshSession.mockResolvedValue({ + grantId: 'grant', sessionId: 'session', userId: 'user-1', clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', @@ -152,6 +166,8 @@ describe('POST /api/mcp-remote-oauth/token', () => { }); expect(mockCreateToken).toHaveBeenCalledWith({ userId: 'user-1', + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + grantId: 'grant', resource, scopes: ['mcp:roomote'], timeoutMs: 3_600_000, @@ -165,12 +181,14 @@ 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'], - }); + expect(mockCreateRefreshSession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + clientId: '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + resource, + scopes: ['mcp:roomote'], + }), + ); }); it('uses the authorization resource when the token request omits it', async () => { @@ -330,7 +348,10 @@ describe('POST /api/mcp-remote-oauth/token', () => { expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); - expect(mockRevokeOnReplay).toHaveBeenCalledWith('session.refresh-token'); + expect(mockRevokeOnReplay).toHaveBeenCalledWith( + 'session.refresh-token', + '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + ); expect(mockRotateRefreshToken).not.toHaveBeenCalled(); expect(mockCreateToken).not.toHaveBeenCalled(); expect(mockPromoteClient).not.toHaveBeenCalled(); @@ -343,7 +364,10 @@ describe('POST /api/mcp-remote-oauth/token', () => { expect(response.status).toBe(400); await expect(response.json()).resolves.toEqual({ error: 'invalid_grant' }); - expect(mockRevokeOnReplay).toHaveBeenCalledWith('session.refresh-token'); + expect(mockRevokeOnReplay).toHaveBeenCalledWith( + 'session.refresh-token', + '2a871f7c-9fac-4b4a-a7d3-cd3f4a329568', + ); expect(mockRotateRefreshToken).not.toHaveBeenCalled(); expect(mockPromoteClient).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 0df5a06280..0a1c7b08fe 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 @@ -6,16 +6,17 @@ import { DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, ROOMOTE_MCP_SCOPE, } from '@roomote/auth'; +import { + exchangeMcpOAuthCode, + getMcpOAuthCode, + getMcpOAuthRefresh, + rotateMcpOAuthRefresh, + revokeMcpOAuthRefresh, +} from '@roomote/redis'; import { - consumeRemoteMcpAuthorizationCode, - createRemoteMcpRefreshSession, - getRemoteMcpAuthorizationCode, getRemoteMcpOAuthClient, - getRemoteMcpRefreshSession, promoteRemoteMcpOAuthClient, - revokeRemoteMcpRefreshSessionOnReplay, - rotateRemoteMcpRefreshToken, verifyPkceChallenge, } from '@/lib/server/mcp-remote-oauth'; import { bootstrapWebRuntimeEnv } from '@/lib/server/bootstrap-runtime-env'; @@ -79,7 +80,7 @@ export async function POST(request: NextRequest) { if (input.grant_type === 'refresh_token') { const [session, client] = await Promise.all([ - getRemoteMcpRefreshSession(input.refresh_token), + getMcpOAuthRefresh(input.refresh_token), getRemoteMcpOAuthClient(input.client_id), ]); if (!session) { @@ -87,7 +88,7 @@ export async function POST(request: NextRequest) { // already-rotated token, its replay revokes the entire session family // per the OAuth 2.0 Security BCP (refresh token rotation with reuse // detection); unknown or expired tokens are ignored. - await revokeRemoteMcpRefreshSessionOnReplay(input.refresh_token); + await revokeMcpOAuthRefresh(input.refresh_token, input.client_id); return oauthError('invalid_grant'); } if ( @@ -104,14 +105,13 @@ export async function POST(request: NextRequest) { const accessToken = await createMcpAccessToken({ userId: session.userId, + clientId: session.clientId, + grantId: session.grantId, resource: session.resource, scopes: [ROOMOTE_MCP_SCOPE], timeoutMs: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, }); - const rotation = await rotateRemoteMcpRefreshToken( - input.refresh_token, - session, - ); + const rotation = await rotateMcpOAuthRefresh(input.refresh_token, session); if (rotation.status !== 'ok') return oauthError('invalid_grant'); // Rotation already committed: renewal must not withhold the new token, // since retrying the old token would revoke the session as a replay. @@ -126,7 +126,7 @@ export async function POST(request: NextRequest) { return tokenResponse(accessToken, rotation.refreshToken); } - const authorization = await getRemoteMcpAuthorizationCode(input.code); + const authorization = await getMcpOAuthCode(input.code); const client = authorization ? await getRemoteMcpOAuthClient(authorization.clientId) : null; @@ -153,24 +153,22 @@ export async function POST(request: NextRequest) { return oauthError('invalid_grant'); } - if (!(await consumeRemoteMcpAuthorizationCode(input.code, authorization))) { + const exchange = await exchangeMcpOAuthCode( + input.code, + authorization, + client.grantTypes.includes('refresh_token'), + ); + if (!exchange) { return oauthError('invalid_grant'); } const accessToken = await createMcpAccessToken({ userId: authorization.userId, + clientId: authorization.clientId, + grantId: authorization.grantId, resource: authorization.resource, scopes: [ROOMOTE_MCP_SCOPE], timeoutMs: DEFAULT_MCP_ACCESS_TOKEN_TIMEOUT_MS, }); - const refreshToken = client.grantTypes.includes('refresh_token') - ? await createRemoteMcpRefreshSession({ - userId: authorization.userId, - clientId: authorization.clientId, - resource: authorization.resource, - scopes: [ROOMOTE_MCP_SCOPE], - }) - : undefined; - - return tokenResponse(accessToken, refreshToken); + return tokenResponse(accessToken, exchange.refreshToken); } diff --git a/apps/web/src/components/settings/ConnectedMcpClients.tsx b/apps/web/src/components/settings/ConnectedMcpClients.tsx new file mode 100644 index 0000000000..bf1f175bb5 --- /dev/null +++ b/apps/web/src/components/settings/ConnectedMcpClients.tsx @@ -0,0 +1,225 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { useTRPC } from '@/trpc/client'; +import { Section } from '@/components/settings'; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyState, + ErrorState, + KeyRound, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from '@/components/system'; + +function timestamp(value: number | null) { + return value === null + ? 'Not recorded' + : new Date(value * 1000).toLocaleString(); +} + +export function ConnectedMcpClients({ + userId, + userLabel = 'your account', +}: { + userId?: string; + userLabel?: string; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [pending, setPending] = useState<{ + grantId?: string; + name: string; + } | null>(null); + const query = useQuery(trpc.mcpAuthorizations.list.queryOptions({ userId })); + const onSuccess = async () => { + setPending(null); + toast.success('MCP access revoked'); + await queryClient.invalidateQueries({ + queryKey: trpc.mcpAuthorizations.list.queryKey(), + }); + }; + const onError = (error: { message: string }) => toast.error(error.message); + const revoke = useMutation( + trpc.mcpAuthorizations.revoke.mutationOptions({ onSuccess, onError }), + ); + const revokeAll = useMutation( + trpc.mcpAuthorizations.revokeAll.mutationOptions({ onSuccess, onError }), + ); + const busy = revoke.isPending || revokeAll.isPending; + + return ( +
setPending({ name: 'all MCP clients' })} + > + Revoke all + + ) : undefined + } + > +

+ Clients authorized to access Roomote as {userLabel}. Revoking access + stops subsequent requests and requires reconnecting the client. +

+ {query.isPending ? ( +
+ + +
+ ) : query.isError ? ( +
+ + +
+ ) : !query.data?.length ? ( + + ) : ( +
+ {query.data.map((grant) => ( +
+
+
+ + {grant.clientName} + + + {grant.status === 'pending' + ? 'Awaiting token exchange' + : 'Active'} + +
+

+ Authorized: {timestamp(grant.authorizedAt)} · Expires:{' '} + {timestamp(grant.expiresAt)} +

+

+ Last request: {timestamp(grant.lastUsedAt)} · Last token + exchange: {timestamp(grant.lastTokenAt)} +

+
+ +
+ ))} +
+ )} + { + if (!open && !busy) setPending(null); + }} + > + + + Revoke {pending?.name}? + + This removes access to {userLabel}. Requests already accepted may + finish. The client must reconnect through browser consent to + regain access. + + + + + + + + +
+ ); +} + +export function UserMcpClients() { + const trpc = useTRPC(); + const users = useQuery(trpc.accessPolicy.get.queryOptions()); + const [selected, setSelected] = useState(''); + if (users.isPending) return ; + if (users.isError) + return ; + const user = + users.data.users.find((entry) => entry.id === selected) ?? + users.data.users[0]; + if (!user) return null; + return ( +
+
+ + +
+ +
+ ); +} diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx index 74c65c72dd..a75d95edad 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.test.tsx @@ -29,6 +29,9 @@ vi.mock('@/components/settings/LinkedAccounts', () => ({ vi.mock('@/components/settings/PersonalIntegrations', () => ({ PersonalIntegrations: () =>
Personal integrations
, })); +vi.mock('@/components/settings/ConnectedMcpClients', () => ({ + ConnectedMcpClients: () =>
Connected MCP clients
, +})); vi.mock('@/components/settings/BrowserNotificationsSection', () => ({ BrowserNotificationsSection: () =>
Browser notifications
, })); diff --git a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx index ba52aaaef3..a7d03e786a 100644 --- a/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx +++ b/apps/web/src/components/settings/pages/PersonalSettingsPage.tsx @@ -8,6 +8,7 @@ import { import { ChangePasswordSection } from '@/components/settings/ChangePasswordSection'; import { LinkedAccounts } from '@/components/settings/LinkedAccounts'; import { PersonalIntegrations } from '@/components/settings/PersonalIntegrations'; +import { ConnectedMcpClients } from '@/components/settings/ConnectedMcpClients'; import { BrowserNotificationsSection } from '@/components/settings/BrowserNotificationsSection'; import { PersonalizationSection, @@ -37,6 +38,7 @@ export function PersonalSettingsPage({ + ); } diff --git a/apps/web/src/components/settings/pages/UsersSettingsPage.tsx b/apps/web/src/components/settings/pages/UsersSettingsPage.tsx index c2c694b851..59d8bb7b12 100644 --- a/apps/web/src/components/settings/pages/UsersSettingsPage.tsx +++ b/apps/web/src/components/settings/pages/UsersSettingsPage.tsx @@ -2,11 +2,13 @@ import { SettingsShell } from '@/components/settings/SettingsShell'; import { UsersSettings } from '@/components/settings/UsersSettings'; +import { UserMcpClients } from '@/components/settings/ConnectedMcpClients'; export function UsersSettingsPage() { return ( + ); } 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 ae11b7a54c..c2eeb09246 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.test.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.test.ts @@ -1,795 +1,188 @@ -const redisState = vi.hoisted(() => new Map()); -const redisSortedSets = vi.hoisted( - () => new Map>(), -); -const redisExpirations = vi.hoisted(() => new Map()); -const expireRedisKeys = vi.hoisted(() => () => { - for (const [key, expiresAt] of redisExpirations) { - if (expiresAt <= Date.now()) { - redisState.delete(key); - redisSortedSets.delete(key); - redisExpirations.delete(key); - } - } +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Redis } from '@roomote/redis'; + +let redis: Redis; +const { createRedis } = vi.hoisted(() => ({ createRedis: vi.fn() })); +vi.mock('@roomote/redis', async (importOriginal) => { + const actual = await importOriginal(); + createRedis.mockImplementation((path: string) => { + process.env.REDIS_URL ??= 'redis://127.0.0.1:16379'; + const base = actual.getRedis(); + const isolated = base.duplicate({ path, retryStrategy: () => 20 }); + base.disconnect(); + return isolated; + }); + return { getRedis: () => redis }; }); - -vi.mock('@roomote/redis', () => ({ - getRedis: () => ({ - set: async (key: string, value: string, _mode: string, ttl: number) => { - redisState.set(key, value); - redisExpirations.set(key, Date.now() + ttl * 1000); - return 'OK'; - }, - get: async (key: string) => { - expireRedisKeys(); - return redisState.get(key) ?? null; - }, - eval: async (script: string, keyCount: number, ...args: string[]) => { - expireRedisKeys(); - const keys = args.slice(0, keyCount); - const values = args.slice(keyCount); - const key = keys[0]!; - if (script.includes("redis.call('ZCARD', KEYS[2])")) { - const indexKey = keys[1]!; - const [clientJson, ttl, 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!); - redisExpirations.set(key, Date.now() + Number(ttl) * 1000); - clients.set(clientId!, Number(expiresAt)); - redisSortedSets.set(indexKey, clients); - return 1; - } - - if (script.includes('local previousSessionId =')) { - const [sessionPrefix, refreshPrefix, sessionJson, marker, sessionId] = - values; - const previousSessionId = redisState.get(keys[2]!); - if (previousSessionId) { - const previousSessionKey = `${sessionPrefix}${previousSessionId}`; - const previous = redisState.get(previousSessionKey); - if (previous) { - const decoded = JSON.parse(previous) as { - currentTokenHash: string; - }; - redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); - } - redisState.delete(previousSessionKey); - } - redisState.set(key, sessionJson!); - redisState.set(keys[1]!, marker!); - redisState.set(keys[2]!, sessionId!); - for (const sessionKey of keys) { - redisExpirations.set( - sessionKey, - Date.now() + Number(values[5]) * 1000, - ); - } - 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!); - for (const rotatedKey of keys.slice(1)) { - redisExpirations.set( - rotatedKey, - Date.now() + Number(values[5]) * 1000, - ); - } - return ['ok']; - } - - if (script.includes('decoded.clientId')) { - const [clientId, refreshPrefix, activeMarker, tokenHash] = values; - if (redisState.get(key) !== activeMarker) return 0; - const session = redisState.get(keys[1]!); - if (!session) return 0; - const decoded = JSON.parse(session) as { - clientId: string; - currentTokenHash: string; - }; - if ( - decoded.clientId !== clientId || - decoded.currentTokenHash !== tokenHash - ) { - return 0; - } - redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); - redisState.delete(key); - redisState.delete(keys[1]!); - return 1; - } - - if (script.includes('local client = tonumber')) { - const globalKey = keys[1]!; - const [, clientLimit, globalLimit] = values; - const clientCount = Number(redisState.get(key) ?? '0'); - const globalCount = Number(redisState.get(globalKey) ?? '0'); - if ( - clientCount >= Number(clientLimit) || - globalCount >= Number(globalLimit) - ) { - return 0; - } - redisState.set(key, String(clientCount + 1)); - redisState.set(globalKey, String(globalCount + 1)); - if (clientCount === 0) - redisExpirations.set(key, Date.now() + Number(values[0]) * 1000); - if (globalCount === 0) - redisExpirations.set( - globalKey, - Date.now() + Number(values[0]) * 1000, - ); - 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; - redisExpirations.set(key, Date.now() + Number(values[0]) * 1000); - redisExpirations.set( - userIndexKey, - Date.now() + Number(values[0]) * 1000, - ); - 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('if marker ~= ARGV[1]')) { - const [rotatedMarker, refreshPrefix] = values; - if (redisState.get(key) !== rotatedMarker) return 0; - const session = redisState.get(keys[1]!); - if (session) { - const decoded = JSON.parse(session) as { - currentTokenHash: string; - }; - redisState.delete(`${refreshPrefix}${decoded.currentTokenHash}`); - } - redisState.delete(keys[1]!); - return 1; - } - - if (script.includes("redis.call('GET'")) { - const value = redisState.get(key) ?? null; - if (value === values[0]) redisState.delete(key); - return value === values[0] ? value : null; - } - - const count = Number(redisState.get(key) ?? '0') + 1; - redisState.set(key, String(count)); - return count; - }, - }), -})); - import { - consumeRemoteMcpAuthorizationCode, consumeRemoteMcpConsentToken, - createRemoteMcpAuthorizationCode, createRemoteMcpConsentToken, - createRemoteMcpRefreshSession, - getRemoteMcpAuthorizationCode, getRemoteMcpOAuthClient, - getRemoteMcpRefreshSession, isAllowedOAuthRedirectUri, isRemoteMcpRegistrationAllowed, promoteRemoteMcpOAuthClient, registerRemoteMcpOAuthClient, - revokeRemoteMcpRefreshSession, - revokeRemoteMcpRefreshSessionOnReplay, - rotateRemoteMcpRefreshToken, verifyPkceChallenge, } from './mcp-remote-oauth'; -describe('remote MCP OAuth state', () => { - beforeEach(() => { - redisState.clear(); - redisSortedSets.clear(); - redisExpirations.clear(); - vi.useFakeTimers({ toFake: ['Date'] }); - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); - }); - - afterEach(() => vi.useRealTimers()); - - it.each([ - ['Claude Code loopback', 'http://localhost:43110/callback'], - ['Claude Code IPv4 loopback', 'http://127.0.0.1:43110/callback'], - ['IPv4 loopback range', 'http://127.42.10.9:43110/callback'], - ['IPv6 loopback', 'http://[::1]:43110/callback'], - ['Cursor desktop app', 'cursor://anysphere.cursor-mcp/oauth/callback'], - [ - 'Cursor hosted callback', - 'https://www.cursor.com/agents/mcp/oauth/callback', - ], - ['Cursor loopback', 'http://localhost:8787/callback'], - ['another Cursor app route', 'cursor://other-client/callback'], - ['Codex desktop app', 'codex://connector/oauth_callback'], - ['Codex loopback', 'http://127.0.0.1:1455/callback'], - [ - 'RFC 8252 private-use app callback', - 'com.example.app:/oauth2redirect/roomote', - ], - ['vendor-independent native app callback', 'example-app:/oauth/callback'], - [ - 'native app callback with registered query state', - 'example-app:/oauth/callback?connection=roomote', - ], - ])('accepts the %s callback', (_client, redirectUri) => { - expect(isAllowedOAuthRedirectUri(redirectUri)).toBe(true); +describe('OAuth registration and consent against real Redis', () => { + let server: ChildProcess; + let directory: string; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'mcp-registration-')); + const socket = join(directory, 'redis.sock'); + server = spawn( + 'redis-server', + [ + '--port', + '0', + '--unixsocket', + socket, + '--save', + '', + '--appendonly', + 'no', + ], + { stdio: 'ignore' }, + ); + redis = createRedis(socket); + redis.on('error', () => {}); + await new Promise((resolve, reject) => { + redis.once('ready', resolve); + server.once('error', reject); + }); }); - - it.each([ - ['Chromium', 'chrome-extension://extension-id/oauth/callback'], - ['Firefox', 'moz-extension://extension-id/oauth/callback'], - ['Safari', 'safari-web-extension://extension-id/oauth/callback'], - ['legacy Edge', 'ms-browser-extension://extension-id/oauth/callback'], - ['generic', 'extension:/oauth/callback'], - ])('rejects the %s browser-extension callback', (_browser, redirectUri) => { - expect(isAllowedOAuthRedirectUri(redirectUri)).toBe(false); + beforeEach(async () => { + await redis.flushdb(); }); - - it('rejects unsafe or malformed callbacks', () => { - expect(isAllowedOAuthRedirectUri('http://client.example/callback')).toBe( - false, - ); - expect(isAllowedOAuthRedirectUri('http://0.0.0.0:43110/callback')).toBe( - false, - ); - expect(isAllowedOAuthRedirectUri('http://[::2]:43110/callback')).toBe( - false, - ); - expect(isAllowedOAuthRedirectUri('example-app:callback')).toBe(false); - expect(isAllowedOAuthRedirectUri('example-app:/')).toBe(false); - expect(isAllowedOAuthRedirectUri('file:///tmp/oauth-callback')).toBe(false); - expect(isAllowedOAuthRedirectUri('javascript:/oauth/callback')).toBe(false); - expect(isAllowedOAuthRedirectUri('data:/oauth/callback')).toBe(false); - expect(isAllowedOAuthRedirectUri('mailto:/oauth/callback')).toBe(false); - expect(isAllowedOAuthRedirectUri('ssh:/oauth/callback')).toBe(false); - expect(isAllowedOAuthRedirectUri('c:/oauth/callback')).toBe(false); - expect( - isAllowedOAuthRedirectUri( - String.raw`https:\\client.example\oauth\callback`, - ), - ).toBe(false); - expect( - isAllowedOAuthRedirectUri(' https://client.example/oauth/callback'), - ).toBe(false); - expect( - isAllowedOAuthRedirectUri( - 'example-app://user@example.com/oauth/callback', - ), - ).toBe(false); - expect( - isAllowedOAuthRedirectUri('example-app:/oauth/callback#fragment'), - ).toBe(false); + afterAll(async () => { + redis.disconnect(); + server.kill('SIGTERM'); + await new Promise((resolve) => server.once('exit', () => resolve())); + await rm(directory, { recursive: true, force: true }); }); - it('stores registered client redirect URIs', async () => { + it('keeps registrations reusable for 90 days and separate from token state', async () => { const client = await registerRemoteMcpOAuthClient({ - clientName: 'Test client', - redirectUris: ['https://client.example/callback'], - }); - - expect(client).toMatchObject({ - clientName: 'Test client', + clientName: 'Client', redirectUris: ['https://client.example/callback'], - grantTypes: ['authorization_code'], }); - await expect( - promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), - ).resolves.toBe(true); + expect(await getRemoteMcpOAuthClient(client.clientId)).toEqual(client); expect( - [...redisSortedSets.entries()].find(([key]) => - key.includes('registered-clients'), - )?.[1].size, - ).toBe(0); - }); - - it('expires unapproved registrations after one hour without reads extending them', async () => { - const client = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - vi.setSystemTime(Date.now() + 3_599_000); - await expect(getRemoteMcpOAuthClient(client.clientId)).resolves.toEqual( - client, + await redis.ttl(`mcp-remote-oauth:client:${client.clientId}`), + ).toBeLessThanOrEqual(3600); + expect(await promoteRemoteMcpOAuthClient(client.clientId, 'alice')).toBe( + true, ); - vi.setSystemTime(Date.now() + 1_000); - await expect(getRemoteMcpOAuthClient(client.clientId)).resolves.toBeNull(); - await expect( - promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), - ).resolves.toBe(false); + expect(await promoteRemoteMcpOAuthClient(client.clientId, 'bob')).toBe( + true, + ); + expect( + await redis.ttl(`mcp-remote-oauth:client:${client.clientId}`), + ).toBeGreaterThan(89 * 86400); + expect(await redis.zcard('mcp-remote-oauth:registered-clients')).toBe(0); + expect(await promoteRemoteMcpOAuthClient('missing', 'alice')).toBe(false); }); - it('bounds pending registrations and reclaims expired capacity', async () => { - for (let index = 0; index < 250; index += 1) { + it('bounds pending registration and reclaims expired entries', async () => { + for (let i = 0; i < 250; i++) await registerRemoteMcpOAuthClient({ redirectUris: ['https://client.example/callback'], }); - } await expect( registerRemoteMcpOAuthClient({ redirectUris: ['https://client.example/callback'], }), ).rejects.toThrow('capacity'); - vi.setSystemTime(Date.now() + 3_600_000); - await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - expect( - redisSortedSets.get('mcp-remote-oauth:registered-clients')?.size, - ).toBe(1); - expect(redisState.size).toBe(1); - }); - - it('reuses an active client after its refresh session expires without extending tokens', async () => { - const client = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - grantTypes: ['authorization_code', 'refresh_token'], - }); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - const token = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: client.clientId, - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - const session = await getRemoteMcpRefreshSession(token); - vi.setSystemTime(Date.now() + 29 * 86_400_000); - const rotation = await rotateRemoteMcpRefreshToken(token, session!); - if (rotation.status !== 'ok') throw new Error('expected rotation'); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - expect( - (await getRemoteMcpRefreshSession(rotation.refreshToken))?.expiresAt, - ).toBe(session!.expiresAt); - - vi.setSystemTime(Date.now() + 86_400_000); - await expect( - getRemoteMcpRefreshSession(rotation.refreshToken), - ).resolves.toBeNull(); - await expect( - rotateRemoteMcpRefreshToken(rotation.refreshToken, session!), - ).resolves.toEqual({ status: 'invalid' }); - await expect(getRemoteMcpOAuthClient(client.clientId)).resolves.toEqual( - client, + const [id] = await redis.zrange( + 'mcp-remote-oauth:registered-clients', + 0, + 0, ); + await redis.zadd('mcp-remote-oauth:registered-clients', 1, id!); await expect( - promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), - ).resolves.toBe(true); - }); - - it('keeps a reused registration beyond its original inactivity deadline', async () => { - const client = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - for (let index = 0; index < 4; index += 1) { - vi.setSystemTime(Date.now() + 60 * 86_400_000); - await expect(getRemoteMcpOAuthClient(client.clientId)).resolves.toEqual( - client, - ); - await expect( - promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), - ).resolves.toBe(true); - } - const expiresAt = Date.now() / 1000 + 90 * 86_400; - for (const [key, members] of redisSortedSets) { - if (key.includes('active-clients')) - expect(members.get(client.clientId)).toBe(expiresAt); - } - }); - - it('upgrades a still-valid legacy 30-day registration on authenticated activity', async () => { - const client = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - const legacyExpiry = Date.now() + 30 * 86_400_000; - redisExpirations.set( - `mcp-remote-oauth:client:${client.clientId}`, - legacyExpiry, - ); - for (const [key, members] of redisSortedSets) { - if (key.includes('active-clients')) { - members.set(client.clientId, legacyExpiry / 1000); - if (key.includes(':user:')) redisExpirations.set(key, legacyExpiry); - } - } - vi.setSystemTime(legacyExpiry - 86_400_000); - await expect( - promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), - ).resolves.toBe(true); - vi.setSystemTime(legacyExpiry + 86_400_000); - await expect(getRemoteMcpOAuthClient(client.clientId)).resolves.toEqual( - client, - ); - }); - - it('reclaims inactive client capacity without expiring recently used clients', async () => { - for (let index = 0; index < 50; index += 1) { - const client = await registerRemoteMcpOAuthClient({ + registerRemoteMcpOAuthClient({ redirectUris: ['https://client.example/callback'], - }); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - } - const activeIds = [ - ...redisSortedSets.get('mcp-remote-oauth:active-clients')!.keys(), - ]; - const retainedId = activeIds[0]!; - vi.setSystemTime(Date.now() + 60 * 86_400_000); - await expect( - promoteRemoteMcpOAuthClient(retainedId, 'user-1'), - ).resolves.toBe(true); - // Anonymous lookups must not keep an otherwise idle registration alive. - await getRemoteMcpOAuthClient(activeIds[1]!); - vi.setSystemTime(Date.now() + 30 * 86_400_000); - await expect(getRemoteMcpOAuthClient(activeIds[1]!)).resolves.toBeNull(); - const replacement = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - await expect( - promoteRemoteMcpOAuthClient(replacement.clientId, 'user-1'), - ).resolves.toBe(true); - for (const [key, members] of redisSortedSets) { - if (key.includes('active-clients')) - expect([...members.keys()]).toEqual([retainedId, replacement.clientId]); - } - await expect(getRemoteMcpOAuthClient(retainedId)).resolves.not.toBeNull(); - }); - - it('preserves global capacity limits when renewing existing clients', async () => { - const client = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - const members = redisSortedSets.get('mcp-remote-oauth:active-clients')!; - for (let index = 1; index < 10_000; index += 1) - members.set(`other-${index}`, Date.now() / 1000 + 90 * 86_400); - await expect( - promoteRemoteMcpOAuthClient(client.clientId, 'user-1'), - ).resolves.toBe(true); - const overflow = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - }); - await expect( - promoteRemoteMcpOAuthClient(overflow.clientId, 'user-2'), - ).resolves.toBe(false); - }); - - it('revokes tokens without deleting the reusable registration or restoring the revoked grant', async () => { - const client = await registerRemoteMcpOAuthClient({ - redirectUris: ['https://client.example/callback'], - grantTypes: ['authorization_code', 'refresh_token'], - }); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - const binding = { - userId: 'user-1', - clientId: client.clientId, - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }; - const token = await createRemoteMcpRefreshSession(binding); - await revokeRemoteMcpRefreshSession(token, 'other-client'); - await expect(getRemoteMcpRefreshSession(token)).resolves.not.toBeNull(); - await revokeRemoteMcpRefreshSession(token, client.clientId); - await expect(getRemoteMcpRefreshSession(token)).resolves.toBeNull(); - await expect(getRemoteMcpOAuthClient(client.clientId)).resolves.toEqual( - client, - ); - await promoteRemoteMcpOAuthClient(client.clientId, 'user-1'); - const replacement = await createRemoteMcpRefreshSession(binding); - await expect( - getRemoteMcpRefreshSession(replacement), - ).resolves.not.toBeNull(); - await expect(getRemoteMcpRefreshSession(token)).resolves.toBeNull(); - }); - - it('consumes authorization codes once', async () => { - const value = { - userId: 'user-1', - clientId: 'client-1', - redirectUri: 'https://client.example/callback', - codeChallenge: 'challenge', - resource: 'https://api.example.com/mcp', - scopes: ['mcp:roomote'], - }; - const code = await createRemoteMcpAuthorizationCode(value); - - await expect(getRemoteMcpAuthorizationCode(code)).resolves.toEqual(value); - await expect(consumeRemoteMcpAuthorizationCode(code, value)).resolves.toBe( - true, - ); - await expect(consumeRemoteMcpAuthorizationCode(code, value)).resolves.toBe( - false, - ); - }); - - it('does not consume a code when the expected binding differs', async () => { - const value = { - userId: 'user-1', - clientId: 'client-1', - redirectUri: 'https://client.example/callback', - codeChallenge: 'challenge', - resource: 'https://api.example.com/mcp', - scopes: ['mcp:roomote'], - }; - const code = await createRemoteMcpAuthorizationCode(value); - - await expect( - consumeRemoteMcpAuthorizationCode(code, { - ...value, - codeChallenge: 'wrong-challenge', }), - ).resolves.toBe(false); - await expect(getRemoteMcpAuthorizationCode(code)).resolves.toEqual(value); + ).resolves.toBeDefined(); }); - it('binds consent approval to the user and authorization request', async () => { + it('binds consent to user and request and consumes it once', async () => { const binding = { - userId: 'user-1', - requestTarget: '/api/mcp-remote-oauth/authorize?client_id=client-1', + userId: 'alice', + requestTarget: '/authorize?bound=request', }; const token = await createRemoteMcpConsentToken(binding); - - await expect( - consumeRemoteMcpConsentToken(token, { - ...binding, - userId: 'attacker', - }), - ).resolves.toBe(false); - await expect(consumeRemoteMcpConsentToken(token, binding)).resolves.toBe( - true, - ); - await expect(consumeRemoteMcpConsentToken(token, binding)).resolves.toBe( - false, - ); - }); - - it('rotates refresh tokens and revokes the session on reuse', async () => { - const refreshToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - const session = await getRemoteMcpRefreshSession(refreshToken); - expect(session).toMatchObject({ userId: 'user-1', clientId: 'client-1' }); - - const rotation = await rotateRemoteMcpRefreshToken(refreshToken, session!); - expect(rotation.status).toBe('ok'); - if (rotation.status !== 'ok') throw new Error('expected refresh rotation'); - await expect( - getRemoteMcpRefreshSession(rotation.refreshToken), - ).resolves.toMatchObject({ userId: 'user-1' }); - - await expect( - rotateRemoteMcpRefreshToken(refreshToken, session!), - ).resolves.toEqual({ status: 'reuse' }); - await expect( - getRemoteMcpRefreshSession(rotation.refreshToken), - ).resolves.toBeNull(); - }); - - it('isolates a replacement authorization from old-family replay', async () => { - const previousToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - const previousSession = await getRemoteMcpRefreshSession(previousToken); - const previousRotation = await rotateRemoteMcpRefreshToken( - previousToken, - previousSession!, - ); - expect(previousRotation.status).toBe('ok'); - - const replacementToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - const replacementSession = - await getRemoteMcpRefreshSession(replacementToken); - expect(replacementSession?.sessionId).not.toBe(previousSession?.sessionId); - if (previousRotation.status !== 'ok') { - throw new Error('expected previous refresh rotation'); - } - await expect( - getRemoteMcpRefreshSession(previousRotation.refreshToken), - ).resolves.toBeNull(); - - await expect( - rotateRemoteMcpRefreshToken(previousToken, previousSession!), - ).resolves.toEqual({ status: 'reuse' }); - await expect(getRemoteMcpRefreshSession(replacementToken)).resolves.toEqual( - replacementSession, - ); - }); - - it('revokes the whole family when a rotated token is replayed', async () => { - const refreshToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - const session = await getRemoteMcpRefreshSession(refreshToken); - const rotation = await rotateRemoteMcpRefreshToken(refreshToken, session!); - if (rotation.status !== 'ok') throw new Error('expected refresh rotation'); - - // The rotated token is no longer a valid session, but its replay must - // still kill the family: the current token dies with it. - await expect(getRemoteMcpRefreshSession(refreshToken)).resolves.toBeNull(); - await expect( - revokeRemoteMcpRefreshSessionOnReplay(refreshToken), - ).resolves.toBe(true); - await expect( - getRemoteMcpRefreshSession(rotation.refreshToken), - ).resolves.toBeNull(); - - // The rotated marker persists, so repeat replays keep reporting the - // replay signal (the family is already dead; there is nothing left to - // revoke). - await expect( - revokeRemoteMcpRefreshSessionOnReplay(refreshToken), - ).resolves.toBe(true); - }); - - it('does not revoke a family for its current token or unknown tokens', async () => { - const refreshToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - - await expect( - revokeRemoteMcpRefreshSessionOnReplay(refreshToken), - ).resolves.toBe(false); - await expect( - revokeRemoteMcpRefreshSessionOnReplay( - `${'b'.repeat(64)}.${'c'.repeat(43)}`, - ), - ).resolves.toBe(false); - await expect( - revokeRemoteMcpRefreshSessionOnReplay('not-a-token'), - ).resolves.toBe(false); - await expect( - getRemoteMcpRefreshSession(refreshToken), - ).resolves.toMatchObject({ userId: 'user-1', clientId: 'client-1' }); - }); - - it('revokes a refresh session by client ID', async () => { - const refreshToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - - await revokeRemoteMcpRefreshSession(refreshToken, 'client-1'); - - await expect(getRemoteMcpRefreshSession(refreshToken)).resolves.toBeNull(); - }); - - it('does not revoke a refresh session with a forged token secret', async () => { - const refreshToken = await createRemoteMcpRefreshSession({ - userId: 'user-1', - clientId: 'client-1', - resource: 'https://roomote.example/mcp', - scopes: ['mcp:roomote'], - }); - const [sessionId] = refreshToken.split('.'); - - await revokeRemoteMcpRefreshSession( - `${sessionId}.${'a'.repeat(43)}`, - 'client-1', - ); - - await expect( - getRemoteMcpRefreshSession(refreshToken), - ).resolves.toMatchObject({ userId: 'user-1', clientId: 'client-1' }); - }); - - it('bounds registrations per client and globally', async () => { - const allowed = []; - for (let index = 0; index < 21; index += 1) { - allowed.push(await isRemoteMcpRegistrationAllowed('same-client')); - } - - expect(allowed.slice(0, 20).every(Boolean)).toBe(true); - expect(allowed[20]).toBe(false); expect( - [...redisState.entries()].find(([key]) => key.includes(':global:'))?.[1], - ).toBe('20'); - }); - - it('does not allocate client buckets after the global limit is full', async () => { - for (let index = 0; index < 100; index += 1) { - await expect( - isRemoteMcpRegistrationAllowed(`client-${index}`), - ).resolves.toBe(true); - } - const keyCountAtLimit = redisState.size; - - await expect( - isRemoteMcpRegistrationAllowed('overflow-client'), - ).resolves.toBe(false); - expect(redisState.size).toBe(keyCountAtLimit); + await consumeRemoteMcpConsentToken(token, { ...binding, userId: 'bob' }), + ).toBe(false); + expect(await consumeRemoteMcpConsentToken(token, binding)).toBe(true); + expect(await consumeRemoteMcpConsentToken(token, binding)).toBe(false); }); - 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('enforces per-client and global registration rate limits', async () => { + for (let i = 0; i < 20; i++) + expect(await isRemoteMcpRegistrationAllowed('caller')).toBe(true); + expect(await isRemoteMcpRegistrationAllowed('caller')).toBe(false); + for (let i = 0; i < 80; i++) + expect(await isRemoteMcpRegistrationAllowed(`caller-${i}`)).toBe(true); + expect(await isRemoteMcpRegistrationAllowed('overflow')).toBe(false); }); +}); +describe('OAuth callback and PKCE validation', () => { + it.each([ + 'http://localhost:43110/callback', + 'http://127.0.0.1:43110/callback', + 'http://127.42.10.9:43110/callback', + 'http://[::1]:43110/callback', + 'cursor://anysphere.cursor-mcp/oauth/callback', + 'codex://connector/oauth_callback', + 'https://www.cursor.com/agents/mcp/oauth/callback', + 'com.example.app:/oauth2redirect/roomote', + 'example-app:/oauth/callback?connection=roomote', + ])('accepts supported callback %s', (uri) => + expect(isAllowedOAuthRedirectUri(uri)).toBe(true), + ); + it.each([ + 'chrome-extension://extension-id/oauth/callback', + 'moz-extension://extension-id/oauth/callback', + 'safari-web-extension://extension-id/oauth/callback', + 'ms-browser-extension://extension-id/oauth/callback', + 'extension:/oauth/callback', + 'http://client.example/callback', + 'http://0.0.0.0:43110/callback', + 'http://[::2]:43110/callback', + 'example-app:callback', + 'example-app:/', + 'file:///tmp/oauth-callback', + 'javascript:/oauth/callback', + 'data:/oauth/callback', + 'mailto:/oauth/callback', + 'ssh:/oauth/callback', + 'c:/oauth/callback', + String.raw`https:\\client.example\oauth\callback`, + ' https://client.example/oauth/callback', + 'example-app://user@example.com/oauth/callback', + 'example-app:/oauth/callback#fragment', + ])('rejects unsafe callback %s', (uri) => + expect(isAllowedOAuthRedirectUri(uri)).toBe(false), + ); it('verifies S256 PKCE challenges', () => { + expect(verifyPkceChallenge('verifier', 'wrong')).toBe(false); + expect( + verifyPkceChallenge( + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wWFOEjXk', + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', + ), + ).toBe(false); expect( verifyPkceChallenge( - 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~', - 'ImpiCd8pp4MveCNnbIS7-GXEtB0xF5HMIDoWqvGA5ig', + 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk', + 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', ), ).toBe(true); }); diff --git a/apps/web/src/lib/server/mcp-remote-oauth.ts b/apps/web/src/lib/server/mcp-remote-oauth.ts index f7d962db93..ac2c0d5c4e 100644 --- a/apps/web/src/lib/server/mcp-remote-oauth.ts +++ b/apps/web/src/lib/server/mcp-remote-oauth.ts @@ -6,8 +6,6 @@ const PENDING_CLIENT_TTL_SECONDS = 60 * 60; // Client identity outlives its tokens so reauthorization can reuse registration. // Successful code exchanges and refreshes renew this bounded inactivity window. const ACTIVE_CLIENT_TTL_SECONDS = 90 * 24 * 60 * 60; -const REFRESH_SESSION_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; @@ -16,15 +14,11 @@ const MAX_REGISTERED_CLIENTS = 250; const MAX_ACTIVE_CLIENTS_PER_USER = 50; const MAX_ACTIVE_CLIENTS_GLOBAL = 10_000; const CLIENT_KEY_PREFIX = 'mcp-remote-oauth:client:'; -const CODE_KEY_PREFIX = 'mcp-remote-oauth:code:'; const CONSENT_KEY_PREFIX = 'mcp-remote-oauth:consent:'; const REGISTRATION_RATE_KEY_PREFIX = 'mcp-remote-oauth:registration-rate:'; const REGISTERED_CLIENTS_KEY = 'mcp-remote-oauth:registered-clients'; const ACTIVE_CLIENTS_KEY = 'mcp-remote-oauth:active-clients'; const ACTIVE_CLIENTS_USER_KEY_PREFIX = 'mcp-remote-oauth:active-clients:user:'; -const REFRESH_SESSION_KEY_PREFIX = 'mcp-remote-oauth:session:'; -const REFRESH_TOKEN_KEY_PREFIX = 'mcp-remote-oauth:refresh:'; -const REFRESH_CLIENT_SESSION_KEY_PREFIX = 'mcp-remote-oauth:client-session:'; const CONSUME_CODE_LUA = ` local value = redis.call('GET', KEYS[1]) @@ -83,80 +77,6 @@ redis.call('EXPIRE', KEYS[4], ARGV[1]) return 1 `; -const CREATE_REFRESH_SESSION_LUA = ` -local previousSessionId = redis.call('GET', KEYS[3]) -if previousSessionId then - local previousSessionKey = ARGV[1] .. previousSessionId - local previous = redis.call('GET', previousSessionKey) - if previous then - local decoded = cjson.decode(previous) - redis.call('DEL', ARGV[2] .. decoded.currentTokenHash) - end - redis.call('DEL', previousSessionKey) -end -redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[6]) -redis.call('SET', KEYS[2], ARGV[4], 'EX', ARGV[6]) -redis.call('SET', KEYS[3], ARGV[5], 'EX', ARGV[6]) -return 1 -`; - -const ROTATE_REFRESH_TOKEN_LUA = ` -local marker = redis.call('GET', KEYS[1]) -if marker == ARGV[1] then - local session = redis.call('GET', KEYS[3]) - if session then - local decoded = cjson.decode(session) - redis.call('DEL', ARGV[5] .. decoded.currentTokenHash) - end - redis.call('DEL', KEYS[3]) - return {'reuse'} -end -if marker ~= ARGV[2] then - return {'invalid'} -end -local session = redis.call('GET', KEYS[3]) -if not session or session ~= ARGV[3] then - return {'invalid'} -end -redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL') -redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[6]) -redis.call('SET', KEYS[3], ARGV[4], 'EX', ARGV[6]) -return {'ok'} -`; - -const REVOKE_SESSION_ON_REPLAY_LUA = ` -local marker = redis.call('GET', KEYS[1]) -if marker ~= ARGV[1] then - return 0 -end -local session = redis.call('GET', KEYS[2]) -if session then - local decoded = cjson.decode(session) - redis.call('DEL', ARGV[2] .. decoded.currentTokenHash) -end -redis.call('DEL', KEYS[2]) -return 1 -`; - -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; @@ -164,75 +84,19 @@ type RemoteMcpOAuthClient = { grantTypes: ('authorization_code' | 'refresh_token')[]; }; -type RemoteMcpAuthorizationCode = { - userId: string; - clientId: string; - redirectUri: string; - codeChallenge: string; - resource: string; - scopes: string[]; -}; - type RemoteMcpConsentBinding = { userId: string; requestTarget: string; }; -type RemoteMcpRefreshSession = { - sessionId: string; - userId: string; - clientId: string; - resource: string; - scopes: string[]; - currentTokenHash: string; - expiresAt: number; -}; - function clientKey(clientId: string): string { return `${CLIENT_KEY_PREFIX}${clientId}`; } -function codeKey(code: string): string { - return `${CODE_KEY_PREFIX}${code}`; -} - function consentKey(token: string): string { return `${CONSENT_KEY_PREFIX}${token}`; } -function refreshClientSessionKey(userId: string, clientId: string): string { - // Public identifiers form a stable Redis lookup key, not a password verifier. - const clientHash = createHash('sha256') - .update(`${userId}\0${clientId}`) - .digest('hex'); - return `${REFRESH_CLIENT_SESSION_KEY_PREFIX}${clientHash}`; -} - -function refreshSessionKey(sessionId: string): string { - return `${REFRESH_SESSION_KEY_PREFIX}${sessionId}`; -} - -function refreshTokenHash(token: string): string { - return createHash('sha256').update(token).digest('hex'); -} - -function refreshTokenKey(tokenHash: string): string { - return `${REFRESH_TOKEN_KEY_PREFIX}${tokenHash}`; -} - -function createRefreshToken(sessionId: string): string { - return `${sessionId}.${randomBytes(32).toString('base64url')}`; -} - -function parseRefreshToken(token: string): string | null { - const separator = token.indexOf('.'); - const sessionId = token.slice(0, separator); - const secret = token.slice(separator + 1); - return /^[a-f0-9]{64}$/.test(sessionId) && secret.length >= 32 - ? sessionId - : null; -} - const NON_NATIVE_APP_REDIRECT_SCHEMES = new Set([ 'about:', 'blob:', @@ -391,20 +255,6 @@ export async function getRemoteMcpOAuthClient( }; } -export async function createRemoteMcpAuthorizationCode( - value: RemoteMcpAuthorizationCode, -): Promise { - const code = randomBytes(32).toString('base64url'); - await getRedis().set( - codeKey(code), - JSON.stringify(value), - 'EX', - AUTHORIZATION_CODE_TTL_SECONDS, - 'NX', - ); - return code; -} - export async function createRemoteMcpConsentToken( value: RemoteMcpConsentBinding, ): Promise { @@ -419,26 +269,6 @@ export async function createRemoteMcpConsentToken( return token; } -export async function getRemoteMcpAuthorizationCode( - code: string, -): Promise { - const value = await getRedis().get(codeKey(code)); - return value ? (JSON.parse(value) as RemoteMcpAuthorizationCode) : null; -} - -export async function consumeRemoteMcpAuthorizationCode( - code: string, - expected: RemoteMcpAuthorizationCode, -): Promise { - const value = await getRedis().eval( - CONSUME_CODE_LUA, - 1, - codeKey(code), - JSON.stringify(expected), - ); - return typeof value === 'string'; -} - export async function consumeRemoteMcpConsentToken( token: string, expected: RemoteMcpConsentBinding, @@ -452,135 +282,6 @@ export async function consumeRemoteMcpConsentToken( return typeof value === 'string'; } -export async function createRemoteMcpRefreshSession(value: { - userId: string; - clientId: string; - resource: string; - scopes: string[]; -}): Promise { - const sessionId = randomBytes(32).toString('hex'); - const refreshToken = createRefreshToken(sessionId); - const tokenHash = refreshTokenHash(refreshToken); - const now = Math.floor(Date.now() / 1000); - const session: RemoteMcpRefreshSession = { - sessionId, - ...value, - currentTokenHash: tokenHash, - expiresAt: now + REFRESH_SESSION_TTL_SECONDS, - }; - await getRedis().eval( - CREATE_REFRESH_SESSION_LUA, - 3, - refreshSessionKey(sessionId), - refreshTokenKey(tokenHash), - refreshClientSessionKey(value.userId, value.clientId), - REFRESH_SESSION_KEY_PREFIX, - REFRESH_TOKEN_KEY_PREFIX, - JSON.stringify(session), - `active:${sessionId}`, - sessionId, - String(REFRESH_SESSION_TTL_SECONDS), - ); - return refreshToken; -} - -export async function getRemoteMcpRefreshSession( - refreshToken: string, -): Promise { - const sessionId = parseRefreshToken(refreshToken); - if (!sessionId) return null; - const tokenHash = refreshTokenHash(refreshToken); - const redis = getRedis(); - const [marker, value] = await Promise.all([ - redis.get(refreshTokenKey(tokenHash)), - redis.get(refreshSessionKey(sessionId)), - ]); - if (marker !== `active:${sessionId}` || !value) return null; - const session = JSON.parse(value) as RemoteMcpRefreshSession; - return session.currentTokenHash === tokenHash ? session : null; -} - -export async function rotateRemoteMcpRefreshToken( - refreshToken: string, - expected: RemoteMcpRefreshSession, -): Promise< - { status: 'ok'; refreshToken: string } | { status: 'invalid' | 'reuse' } -> { - const sessionId = parseRefreshToken(refreshToken); - if (!sessionId || sessionId !== expected.sessionId) { - return { status: 'invalid' }; - } - const now = Math.floor(Date.now() / 1000); - const ttl = expected.expiresAt - now; - if (ttl <= 0) return { status: 'invalid' }; - - const nextRefreshToken = createRefreshToken(sessionId); - const nextTokenHash = refreshTokenHash(nextRefreshToken); - const nextSession = { ...expected, currentTokenHash: nextTokenHash }; - const oldTokenHash = refreshTokenHash(refreshToken); - const result = (await getRedis().eval( - ROTATE_REFRESH_TOKEN_LUA, - 3, - refreshTokenKey(oldTokenHash), - refreshTokenKey(nextTokenHash), - refreshSessionKey(sessionId), - `rotated:${sessionId}`, - `active:${sessionId}`, - JSON.stringify(expected), - JSON.stringify(nextSession), - REFRESH_TOKEN_KEY_PREFIX, - String(ttl), - )) as string[]; - if (result[0] !== 'ok') { - return { status: result[0] === 'reuse' ? 'reuse' : 'invalid' }; - } - return { status: 'ok', refreshToken: nextRefreshToken }; -} - -export async function revokeRemoteMcpRefreshSession( - refreshToken: string, - clientId: string, -): Promise { - const sessionId = parseRefreshToken(refreshToken); - if (!sessionId) return; - const tokenHash = refreshTokenHash(refreshToken); - await getRedis().eval( - REVOKE_REFRESH_SESSION_LUA, - 2, - refreshTokenKey(tokenHash), - refreshSessionKey(sessionId), - clientId, - REFRESH_TOKEN_KEY_PREFIX, - `active:${sessionId}`, - tokenHash, - ); -} - -/** - * Replay of an already-rotated refresh token revokes the whole session family - * (OAuth 2.0 Security BCP): the session and its current token are deleted, so - * every descendant token dies with them. Only tokens carrying the `rotated:` - * marker left behind by a successful rotation trigger this; unknown or expired - * tokens are ignored so random garbage cannot kill a live family. Returns true - * when a replay was detected and the family was revoked. - */ -export async function revokeRemoteMcpRefreshSessionOnReplay( - refreshToken: string, -): Promise { - const sessionId = parseRefreshToken(refreshToken); - if (!sessionId) return false; - const tokenHash = refreshTokenHash(refreshToken); - const revoked = await getRedis().eval( - REVOKE_SESSION_ON_REPLAY_LUA, - 2, - refreshTokenKey(tokenHash), - refreshSessionKey(sessionId), - `rotated:${sessionId}`, - REFRESH_TOKEN_KEY_PREFIX, - ); - return revoked === 1; -} - export async function isRemoteMcpRegistrationAllowed( registrationFingerprint: string, ): Promise { diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 619a25f4a9..d99a1c1740 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { DEPLOYMENT_EXPERIMENT_IDS } from '@roomote/feature-flags'; import { instanceSkillsRouter } from './instance-skills'; +import { mcpAuthorizationsRouter } from './mcp-authorizations'; import { vulnerabilityWatchRouter } from './vulnerability-watch'; import { publicAuthTokenTimeoutMsSchema, @@ -3762,6 +3763,7 @@ export const appRouter = createRouter({ }), instanceSkills: instanceSkillsRouter, + mcpAuthorizations: mcpAuthorizationsRouter, vulnerabilityWatch: vulnerabilityWatchRouter, customSkills: createRouter({ diff --git a/apps/web/src/trpc/routers/mcp-authorizations.test.ts b/apps/web/src/trpc/routers/mcp-authorizations.test.ts new file mode 100644 index 0000000000..1166168508 --- /dev/null +++ b/apps/web/src/trpc/routers/mcp-authorizations.test.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'node:crypto'; +import { db, eq, userFactory, users } from '@roomote/db/server'; +import { + createMcpOAuthCode, + getMcpOAuthCode, + exchangeMcpOAuthCode, + isMcpOAuthGrantLive, + revokeMcpOAuthGrants, +} from '@roomote/redis'; +import type { UserAuthSuccess } from '@/types'; + +vi.mock('@/lib/server', () => ({ authorize: vi.fn() })); +import { mcpAuthorizationsRouter } from './mcp-authorizations'; + +describe('MCP authorization ownership with real Redis and member fixtures', () => { + const actors: UserAuthSuccess[] = []; + async function actor(role: 'admin' | 'member') { + const user = await userFactory.create({ role }); + const auth = { + success: true, + userType: 'user', + userId: user.id, + isAdmin: role === 'admin', + } as UserAuthSuccess; + actors.push(auth); + return auth; + } + const caller = (auth: UserAuthSuccess) => + mcpAuthorizationsRouter.createCaller({ auth }); + async function grant(auth: UserAuthSuccess, clientId: string) { + const code = await createMcpOAuthCode({ + userId: auth.userId, + clientId, + resource: 'https://example.com/mcp', + scopes: ['mcp:roomote'], + redirectUri: 'https://client.example/callback', + codeChallenge: 'challenge', + }); + const value = (await getMcpOAuthCode(code))!; + await exchangeMcpOAuthCode(code, value, true); + return value; + } + afterEach(async () => { + for (const a of actors.splice(0)) { + await revokeMcpOAuthGrants(a.userId); + await db.delete(users).where(eq(users.id, a.userId)); + } + }); + it('allows own and admin actions and denies cross-user reads, single and bulk revoke', async () => { + const member = await actor('member'); + const other = await actor('member'); + const admin = await actor('admin'); + const clientId = randomUUID(); + const a = await grant(member, clientId); + const b = await grant(other, clientId); + expect(await caller(member).list({})).toMatchObject([ + { grantId: a.grantId }, + ]); + expect(await caller(admin).list({ userId: other.userId })).toMatchObject([ + { grantId: b.grantId }, + ]); + await expect( + caller(member).list({ userId: other.userId }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + caller(member).revoke({ userId: other.userId, grantId: b.grantId }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + caller(member).revokeAll({ userId: other.userId }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + // A guessed grant ID under one's own target is harmless. + await caller(member).revoke({ grantId: b.grantId }); + expect(await isMcpOAuthGrantLive(b)).toBe(true); + await caller(admin).revokeAll({ userId: member.userId }); + expect(await isMcpOAuthGrantLive(a)).toBe(false); + expect(await isMcpOAuthGrantLive(b)).toBe(true); + await caller(other).revoke({ grantId: b.grantId }); + expect(await isMcpOAuthGrantLive(b)).toBe(false); + }); + it('rejects unauthenticated callers', async () => { + const anonymous = mcpAuthorizationsRouter.createCaller({ + auth: { success: false, error: 'not signed in' }, + }); + await expect(anonymous.list({})).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); +}); diff --git a/apps/web/src/trpc/routers/mcp-authorizations.ts b/apps/web/src/trpc/routers/mcp-authorizations.ts new file mode 100644 index 0000000000..be41c7871e --- /dev/null +++ b/apps/web/src/trpc/routers/mcp-authorizations.ts @@ -0,0 +1,44 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { listMcpOAuthGrants, revokeMcpOAuthGrants } from '@roomote/redis'; + +import { createRouter, protectedProcedure } from '../init'; + +const target = z + .object({ userId: z.string().min(1).max(256).optional() }) + .strict(); +function authorizedTarget( + auth: { userId: string; isAdmin: boolean }, + userId?: string, +) { + if (userId && userId !== auth.userId && !auth.isAdmin) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Only admins can manage another user’s MCP clients', + }); + } + return userId ?? auth.userId; +} + +export const mcpAuthorizationsRouter = createRouter({ + list: protectedProcedure + .input(target) + .query(({ ctx: { auth }, input }) => + listMcpOAuthGrants(authorizedTarget(auth, input.userId)), + ), + revoke: protectedProcedure + .input(target.extend({ grantId: z.string().regex(/^[a-f0-9]{64}$/) })) + .mutation(async ({ ctx: { auth }, input }) => { + await revokeMcpOAuthGrants( + authorizedTarget(auth, input.userId), + input.grantId, + ); + return { success: true }; + }), + revokeAll: protectedProcedure + .input(target) + .mutation(async ({ ctx: { auth }, input }) => { + await revokeMcpOAuthGrants(authorizedTarget(auth, input.userId)); + return { success: true }; + }), +}); diff --git a/packages/auth/src/__tests__/mcp-access-token.test.ts b/packages/auth/src/__tests__/mcp-access-token.test.ts index d390bc059c..5dc5a2de3a 100644 --- a/packages/auth/src/__tests__/mcp-access-token.test.ts +++ b/packages/auth/src/__tests__/mcp-access-token.test.ts @@ -46,6 +46,8 @@ describe('MCP access tokens', () => { await expect( createMcpAccessToken({ userId: 'user-1', + clientId: 'client', + grantId: 'grant', resource, scopes: [ROOMOTE_MCP_SCOPE], timeoutMs: 60_000, @@ -56,7 +58,13 @@ describe('MCP access tokens', () => { expect.objectContaining({ sub: 'user-1', aud: resource, - r: { u: 'user-1', t: 'mcp', s: [ROOMOTE_MCP_SCOPE] }, + r: { + u: 'user-1', + t: 'mcp', + s: [ROOMOTE_MCP_SCOPE], + c: 'client', + g: 'grant', + }, }), testKeyPair.privateKey, { algorithm: 'ES256' }, @@ -73,11 +81,19 @@ describe('MCP access tokens', () => { iat: now, nbf: now - 1, v: 1, - r: { u: 'user-1', t: 'mcp', s: [ROOMOTE_MCP_SCOPE] }, + r: { + u: 'user-1', + t: 'mcp', + s: [ROOMOTE_MCP_SCOPE], + c: 'client', + g: 'grant', + }, }); await expect(validateMcpAccessToken('token')).resolves.toEqual({ userId: 'user-1', + clientId: 'client', + grantId: 'grant', tokenType: 'mcp', version: 1, resource, @@ -85,6 +101,21 @@ describe('MCP access tokens', () => { }); }); + it('rejects legacy JWTs without a grant identity', 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('legacy')).rejects.toThrow(); + }); + it('preserves a single-origin API proxy prefix in MCP URLs', () => { const apiBaseUrl = 'https://roomote.example/_roomote-api'; diff --git a/packages/auth/src/__tests__/session-broker-token.test.ts b/packages/auth/src/__tests__/session-broker-token.test.ts index 0e7fdeffc0..ce41b8ad9a 100644 --- a/packages/auth/src/__tests__/session-broker-token.test.ts +++ b/packages/auth/src/__tests__/session-broker-token.test.ts @@ -74,6 +74,8 @@ it('does not interchange broker tokens with ordinary auth, run or public MCP tok timeoutMs: 60_000, }), await createMcpAccessToken({ + clientId: 'client', + grantId: 'grant', userId: identity.userId, resource: 'https://api.example.com/mcp', scopes: ['mcp:roomote'], diff --git a/packages/auth/src/mcp-access-token.ts b/packages/auth/src/mcp-access-token.ts index a6df0a718d..b00e8297a0 100644 --- a/packages/auth/src/mcp-access-token.ts +++ b/packages/auth/src/mcp-access-token.ts @@ -52,6 +52,8 @@ export function getRoomoteMcpProtectedResourceMetadataUrl( const createMcpAccessTokenOptionsSchema = z.object({ userId: z.string().min(1), + clientId: z.string().min(1), + grantId: z.string().min(1), resource: z.string().url(), scopes: z.array(z.literal(ROOMOTE_MCP_SCOPE)).min(1), timeoutMs: z @@ -68,7 +70,7 @@ export type CreateMcpAccessTokenOptions = z.infer< export async function createMcpAccessToken( options: CreateMcpAccessTokenOptions, ): Promise { - const { userId, resource, scopes, timeoutMs } = + const { userId, clientId, grantId, resource, scopes, timeoutMs } = createMcpAccessTokenOptionsSchema.parse(options); const now = Math.floor(Date.now() / 1000); @@ -84,6 +86,8 @@ export async function createMcpAccessToken( u: userId, t: 'mcp', s: scopes, + c: clientId, + g: grantId, }, }; @@ -112,6 +116,8 @@ export async function validateMcpAccessToken( return { userId: payload.r.u, + clientId: payload.r.c, + grantId: payload.r.g, tokenType: 'mcp', version: payload.v, resource: payload.aud, diff --git a/packages/redis/src/client.ts b/packages/redis/src/client.ts index ddbb16f494..848b12b1ff 100644 --- a/packages/redis/src/client.ts +++ b/packages/redis/src/client.ts @@ -3,6 +3,17 @@ import { Redis } from 'ioredis'; import { Env } from '@roomote/env'; let redis: Redis | null = null; +let mcpOAuthRedis: Redis | null = null; + +/** Credential checks must fail promptly rather than queue forever on outages. */ +export function getMcpOAuthRedis(): Redis { + mcpOAuthRedis ??= new Redis(resolveRedisUrl(), { + maxRetriesPerRequest: 1, + connectTimeout: 2000, + commandTimeout: 2000, + }); + return mcpOAuthRedis; +} function resolveRedisUrl(): string { // In apps/web on Vercel, dotenvx decrypts into process.env at runtime after diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index f4747022ce..5e9b444c5d 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -2,6 +2,18 @@ import type { Redis } from 'ioredis'; export type { Redis } from 'ioredis'; export { getRedis } from './client'; +export { + createMcpOAuthCode, + getMcpOAuthCode, + exchangeMcpOAuthCode, + getMcpOAuthRefresh, + rotateMcpOAuthRefresh, + revokeMcpOAuthRefresh, + isMcpOAuthGrantLive, + listMcpOAuthGrants, + revokeMcpOAuthGrants, + type McpOAuthGrantSummary, +} from './mcp-oauth-grants'; export { disconnectSessionPresence, disconnectSessionBrowserAttentionLease, diff --git a/packages/redis/src/mcp-oauth-grants.test.ts b/packages/redis/src/mcp-oauth-grants.test.ts new file mode 100644 index 0000000000..fed4fde12c --- /dev/null +++ b/packages/redis/src/mcp-oauth-grants.test.ts @@ -0,0 +1,314 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Redis } from 'ioredis'; + +let redis: Redis; +vi.mock('./client', () => ({ getMcpOAuthRedis: () => redis })); + +import { + createMcpOAuthCode, + getMcpOAuthCode, + exchangeMcpOAuthCode, + getMcpOAuthRefresh, + rotateMcpOAuthRefresh, + revokeMcpOAuthRefresh, + isMcpOAuthGrantLive, + listMcpOAuthGrants, + revokeMcpOAuthGrants, +} from './mcp-oauth-grants'; + +const prefix = 'mcp-remote-oauth:grants:v2:'; +const hash = (v: string) => createHash('sha256').update(v).digest('hex'); +const auth = (userId = 'alice', clientId = 'shared-client') => ({ + userId, + clientId, + resource: 'https://example.com/mcp', + scopes: ['mcp:roomote'], + redirectUri: 'https://client.example/callback', + codeChallenge: 'pkce', +}); +async function authorize( + userId = 'alice', + clientId = 'shared-client', + refresh = true, +) { + const code = await createMcpOAuthCode(auth(userId, clientId), ''); + const grant = (await getMcpOAuthCode(code))!; + const result = (await exchangeMcpOAuthCode(code, grant, refresh))!; + return { code, grant, ...result }; +} + +describe('MCP grants against real Redis Lua', () => { + let server: ChildProcess; + let directory: string; + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'mcp-grants-')); + const socket = join(directory, 'redis.sock'); + server = spawn( + 'redis-server', + [ + '--port', + '0', + '--unixsocket', + socket, + '--save', + '', + '--appendonly', + 'no', + ], + { stdio: 'ignore' }, + ); + redis = new Redis({ path: socket, retryStrategy: () => 20 }); + redis.on('error', () => {}); + await new Promise((resolve, reject) => { + redis.once('ready', resolve); + server.once('error', reject); + }); + }); + beforeEach(async () => { + await redis.flushdb(); + }); + afterAll(async () => { + redis.disconnect(); + server.kill('SIGTERM'); + await new Promise((resolve) => server.once('exit', () => resolve())); + await rm(directory, { recursive: true, force: true }); + }); + + it('tracks code-only grants from consent, exchanges once and expires without a refresh token', async () => { + const code = await createMcpOAuthCode(auth(), 'Client\nname'); + expect(await listMcpOAuthGrants('alice')).toMatchObject([ + { clientName: 'Clientname', status: 'pending', lastUsedAt: null }, + ]); + const grant = (await getMcpOAuthCode(code))!; + expect(await isMcpOAuthGrantLive(grant)).toBe(false); + expect(await exchangeMcpOAuthCode(code, grant, false)).toEqual({ + refreshToken: undefined, + }); + expect(await exchangeMcpOAuthCode(code, grant, false)).toBeNull(); + expect(await isMcpOAuthGrantLive(grant)).toBe(true); + expect(await listMcpOAuthGrants('alice')).toMatchObject([ + { + status: 'active', + lastUsedAt: expect.any(Number), + lastTokenAt: expect.any(Number), + }, + ]); + expect( + await redis.ttl(`${prefix}grant:${grant.grantId}`), + ).toBeLessThanOrEqual(3600); + await redis.del(`${prefix}grant:${grant.grantId}`); + expect(await isMcpOAuthGrantLive(grant)).toBe(false); + expect(await listMcpOAuthGrants('alice')).toEqual([]); + }); + + it('isolates users sharing registration and makes revoke idempotent and generation-bound', async () => { + await redis.set('mcp-remote-oauth:client:shared-client', 'registration'); + const a = await authorize(); + const b = await authorize('bob'); + await revokeMcpOAuthGrants('bob', a.grant.grantId); + expect(await isMcpOAuthGrantLive(a.grant)).toBe(true); + await revokeMcpOAuthGrants('alice', a.grant.grantId); + await revokeMcpOAuthGrants('alice', a.grant.grantId); + expect(await isMcpOAuthGrantLive(a.grant)).toBe(false); + expect(await getMcpOAuthRefresh(a.refreshToken!)).toBeNull(); + const next = await authorize(); + expect(await isMcpOAuthGrantLive(next.grant)).toBe(true); + expect(await isMcpOAuthGrantLive(a.grant)).toBe(false); + expect(await isMcpOAuthGrantLive(b.grant)).toBe(true); + expect(await redis.get('mcp-remote-oauth:client:shared-client')).toBe( + 'registration', + ); + }); + + it('revokes pending codes and all clients only for the selected user', async () => { + const a = await authorize(); + const code = await createMcpOAuthCode(auth('alice', 'code-only')); + const pending = (await getMcpOAuthCode(code))!; + const b = await authorize('bob'); + await revokeMcpOAuthGrants('alice'); + expect(await listMcpOAuthGrants('alice')).toEqual([]); + expect(await exchangeMcpOAuthCode(code, pending, true)).toBeNull(); + expect(await isMcpOAuthGrantLive(a.grant)).toBe(false); + expect(await isMcpOAuthGrantLive(b.grant)).toBe(true); + }); + + it('never resurrects a grant when revoke races a code exchange or refresh', async () => { + for (let i = 0; i < 20; i++) { + const code = await createMcpOAuthCode(auth()); + const grant = (await getMcpOAuthCode(code))!; + await Promise.all([ + exchangeMcpOAuthCode(code, grant, true), + revokeMcpOAuthGrants('alice', grant.grantId), + ]); + expect(await isMcpOAuthGrantLive(grant)).toBe(false); + const active = await authorize(); + await Promise.all([ + rotateMcpOAuthRefresh(active.refreshToken!, active.grant), + revokeMcpOAuthGrants('alice'), + ]); + expect(await isMcpOAuthGrantLive(active.grant)).toBe(false); + } + }); + + it('handles rotation, replay, unknown tokens and RFC7009 revocation with client isolation', async () => { + const { grant, refreshToken } = await authorize(); + const rotated = await rotateMcpOAuthRefresh(refreshToken!, grant); + expect(rotated.status).toBe('ok'); + expect(await getMcpOAuthRefresh(refreshToken!)).toBeNull(); + await revokeMcpOAuthRefresh(rotated.refreshToken, 'different-client'); + await revokeMcpOAuthRefresh(`${grant.grantId}.${'a'.repeat(64)}`); + expect(await isMcpOAuthGrantLive(grant)).toBe(true); + await revokeMcpOAuthRefresh(refreshToken!, grant.clientId); + expect(await getMcpOAuthRefresh(rotated.refreshToken)).toBeNull(); + expect(await isMcpOAuthGrantLive(grant)).toBe(false); + expect((await rotateMcpOAuthRefresh(refreshToken!, grant)).status).toBe( + 'reuse', + ); + }); + + it('caps pending and active grants together and reclaims expired capacity', async () => { + for (let i = 0; i < 50; i++) + await createMcpOAuthCode(auth('alice', `client-${i}`)); + await expect(createMcpOAuthCode(auth('alice', 'overflow'))).rejects.toThrow( + 'capacity', + ); + const all = await listMcpOAuthGrants('alice'); + const id = all[0]!.grantId; + await redis.del(`${prefix}grant:${id}`); + await redis.zadd(`${prefix}user:${hash('alice')}`, 1, id); + await redis.zadd(`${prefix}all`, 1, id); + await expect( + createMcpOAuthCode(auth('alice', 'now-fits')), + ).resolves.toBeTypeOf('string'); + }); + + async function legacy(userId = 'alice', clientId = 'shared-client') { + const sessionId = randomBytes(32).toString('hex'); + const token = `${sessionId}.${randomBytes(32).toString('base64url')}`; + const expiresAt = Math.floor(Date.now() / 1000) + 900; + const session = { + sessionId, + ...auth(userId, clientId), + currentTokenHash: hash(token), + expiresAt, + }; + await redis.set( + `mcp-remote-oauth:session:${sessionId}`, + JSON.stringify(session), + 'EX', + 900, + ); + await redis.set( + `mcp-remote-oauth:refresh:${hash(token)}`, + `active:${sessionId}`, + 'EX', + 900, + ); + await redis.set( + `mcp-remote-oauth:client-session:${hash(`${userId}\0${clientId}`)}`, + sessionId, + 'EX', + 900, + ); + await redis.zadd( + `mcp-remote-oauth:active-clients:user:${hash(userId)}`, + expiresAt + 90 * 86400, + clientId, + ); + return { token, sessionId, expiresAt }; + } + + it('backfills legacy families with original expiry and unknown historical timestamps', async () => { + const old = await legacy(); + const [summary] = await listMcpOAuthGrants('alice'); + expect(summary).toMatchObject({ + grantId: old.sessionId, + authorizedAt: null, + lastUsedAt: null, + }); + expect(summary!.expiresAt).toBeLessThanOrEqual(old.expiresAt); + expect( + await redis.get(`mcp-remote-oauth:session:${old.sessionId}`), + ).toBeNull(); + const grant = (await getMcpOAuthRefresh(old.token))!; + const next = await rotateMcpOAuthRefresh(old.token, grant); + expect(next.status).toBe('ok'); + expect(await isMcpOAuthGrantLive(grant)).toBe(true); + await revokeMcpOAuthRefresh(old.token); + expect(await isMcpOAuthGrantLive(grant)).toBe(false); + }); + + it('blocks unmigrated legacy sessions after bulk revoke, including migration races', async () => { + const b = await legacy('bob'); + for (let i = 0; i < 10; i++) { + const a = await legacy(); + await Promise.all([ + getMcpOAuthRefresh(a.token), + revokeMcpOAuthGrants('alice'), + ]); + expect(await getMcpOAuthRefresh(a.token)).toBeNull(); + expect(await listMcpOAuthGrants('alice')).toEqual([]); + } + expect(await getMcpOAuthRefresh(b.token)).not.toBeNull(); + }); + + it('rejects expired, rotated and unknown legacy sessions without manufacturing a grant', async () => { + const a = await legacy(); + await redis.set( + `mcp-remote-oauth:refresh:${hash(a.token)}`, + `rotated:${a.sessionId}`, + 'EX', + 900, + ); + expect(await getMcpOAuthRefresh(a.token)).toBeNull(); + expect(await listMcpOAuthGrants('alice')).toEqual([]); + await redis.del(`mcp-remote-oauth:session:${a.sessionId}`); + expect(await getMcpOAuthRefresh(a.token)).toBeNull(); + expect(await isMcpOAuthGrantLive({ userId: 'alice' })).toBe(false); + }); + + it('does not accept OAuth when Redis fails', async () => { + const { grant } = await authorize(); + const failing = vi + .spyOn(redis, 'eval') + .mockRejectedValueOnce(new Error('Redis unavailable')); + await expect(isMcpOAuthGrantLive(grant)).rejects.toThrow( + 'Redis unavailable', + ); + failing.mockRestore(); + }); + + it('checks identity and absolute expiry even if Redis has retained the record', async () => { + const { grant } = await authorize(); + expect(await isMcpOAuthGrantLive({ ...grant, userId: 'bob' })).toBe(false); + expect(await isMcpOAuthGrantLive({ ...grant, clientId: 'other' })).toBe( + false, + ); + const key = `${prefix}grant:${grant.grantId}`; + const stored = JSON.parse((await redis.get(key))!); + await redis.set(key, JSON.stringify({ ...stored, expiresAt: 1 }), 'EX', 60); + expect(await isMcpOAuthGrantLive(grant)).toBe(false); + expect(await listMcpOAuthGrants('alice')).toEqual([]); + }); + + it('does not revive legacy sessions when consent or single revoke races backfill', async () => { + const old = await legacy(); + const [rows] = await Promise.all([ + listMcpOAuthGrants('alice'), + createMcpOAuthCode(auth()), + ]); + if (rows[0]) await revokeMcpOAuthGrants('alice', rows[0].grantId); + expect(await getMcpOAuthRefresh(old.token)).toBeNull(); + const other = await legacy('bob'); + const [summary] = await listMcpOAuthGrants('bob'); + await Promise.all([ + getMcpOAuthRefresh(other.token), + revokeMcpOAuthGrants('bob', summary!.grantId), + ]); + expect(await getMcpOAuthRefresh(other.token)).toBeNull(); + }); +}); diff --git a/packages/redis/src/mcp-oauth-grants.ts b/packages/redis/src/mcp-oauth-grants.ts new file mode 100644 index 0000000000..7c4815dab2 --- /dev/null +++ b/packages/redis/src/mcp-oauth-grants.ts @@ -0,0 +1,473 @@ +import { createHash, randomBytes } from 'node:crypto'; + +import { getMcpOAuthRedis as getRedis } from './client'; + +// A new namespace intentionally excludes legacy, untracked authorizations. +const PREFIX = 'mcp-remote-oauth:grants:v2:'; +const CODE_TTL = 300; +const ACCESS_TTL = 3600; +const REFRESH_TTL = 30 * 24 * 3600; +const USER_LIMIT = 50; +const GLOBAL_LIMIT = 10_000; +const hash = (value: string) => + createHash('sha256').update(value).digest('hex'); +const grantKey = (id: string) => `${PREFIX}grant:${id}`; +const userKey = (id: string) => `${PREFIX}user:${hash(id)}`; +const pairKey = (userId: string, clientId: string) => + `${PREFIX}pair:${hash(`${userId}\0${clientId}`)}`; +const legacyUserKey = (userId: string) => + `mcp-remote-oauth:active-clients:user:${hash(userId)}`; +const legacyPairKey = (userId: string, clientId: string) => + `mcp-remote-oauth:client-session:${hash(`${userId}\0${clientId}`)}`; +const codeKey = (code: string) => `${PREFIX}code:${hash(code)}`; +const tokenKey = (token: string) => `${PREFIX}token:${hash(token)}`; +const secret = () => randomBytes(32).toString('hex'); +const nowSeconds = () => Math.floor(Date.now() / 1000); + +interface McpOAuthAuthorization { + userId: string; + clientId: string; + redirectUri: string; + codeChallenge: string; + resource: string; + scopes: string[]; +} + +interface Grant extends McpOAuthAuthorization { + grantId: string; + clientName: string; + authorizedAt: number | null; + expiresAt: number; + lastUsedAt: number | null; + lastTokenAt: number | null; + status: 'pending' | 'active'; + codeKey?: string; + currentTokenKey?: string; +} + +export type McpOAuthGrantSummary = Pick< + Grant, + | 'grantId' + | 'userId' + | 'clientName' + | 'authorizedAt' + | 'expiresAt' + | 'lastUsedAt' + | 'lastTokenAt' + | 'status' +>; + +// Every operation that can admit a credential and every revocation is one Redis +// transaction. Never re-create a grant from a code/session read outside Lua. +const COMMON = ` +local prefix = ARGV[1] +local now = tonumber(ARGV[2]) +local function read(id) + local raw = redis.call('GET', prefix .. 'grant:' .. id) + if not raw then return nil end + local g = cjson.decode(raw) + if g.expiresAt <= now then return nil end + return g +end +local function removeLegacy(pair, userId) + local id = redis.call('GET', pair) + if not id then return end + local key = 'mcp-remote-oauth:session:' .. id + local raw = redis.call('GET', key) + if raw then + local old = cjson.decode(raw) + if old.userId ~= userId then return end + redis.call('DEL', 'mcp-remote-oauth:refresh:' .. old.currentTokenHash, key) + end + redis.call('DEL', pair) +end +local function remove(id) + local key = prefix .. 'grant:' .. id + local raw = redis.call('GET', key) + if raw then + local g = cjson.decode(raw) + redis.call('SET', g.pairKey .. ':legacy-blocked', '1', 'EX', ${31 * 24 * 3600}) + if g.codeKey then redis.call('DEL', g.codeKey) end + if g.currentTokenKey then redis.call('DEL', g.currentTokenKey) end + if redis.call('GET', g.pairKey) == id then redis.call('DEL', g.pairKey) end + redis.call('ZREM', g.userKey, id) + removeLegacy(g.legacyPairKey, g.userId) + redis.call('ZREM', g.legacyUserKey, g.clientId) + end + redis.call('DEL', key) + redis.call('ZREM', prefix .. 'all', id) +end +local function save(g) + local ttl = g.expiresAt - now + redis.call('SET', prefix .. 'grant:' .. g.grantId, cjson.encode(g), 'EX', ttl) + redis.call('SET', g.pairKey, g.grantId, 'EX', ttl) + redis.call('ZADD', g.userKey, g.expiresAt, g.grantId) + redis.call('EXPIRE', g.userKey, ${REFRESH_TTL}) + redis.call('ZADD', prefix .. 'all', g.expiresAt, g.grantId) +end +`; + +const CREATE = `${COMMON} +local g = cjson.decode(ARGV[3]) +local previous = redis.call('GET', g.pairKey) +redis.call('ZREMRANGEBYSCORE', g.userKey, '-inf', now) +redis.call('ZREMRANGEBYSCORE', prefix .. 'all', '-inf', now) +local allowance = 0 +if previous and read(previous) then allowance = 1 end +if redis.call('ZCARD', g.userKey) - allowance >= ${USER_LIMIT} or + redis.call('ZCARD', prefix .. 'all') - allowance >= ${GLOBAL_LIMIT} then return 0 end +if previous then remove(previous) end +removeLegacy(g.legacyPairKey, g.userId) +redis.call('SET', g.pairKey .. ':legacy-blocked', '1', 'EX', ${31 * 24 * 3600}) +save(g) +redis.call('SET', g.codeKey, g.grantId, 'EX', ${CODE_TTL}) +return 1 +`; + +const EXCHANGE = `${COMMON} +local g = read(ARGV[3]) +if not g or g.status ~= 'pending' or g.codeKey ~= KEYS[1] or + redis.call('GET', KEYS[1]) ~= g.grantId then return 0 end +redis.call('DEL', KEYS[1]) +g.codeKey = nil +g.status = 'active' +g.lastTokenAt = now +g.expiresAt = now + tonumber(ARGV[4]) +if ARGV[5] ~= '' then + g.currentTokenKey = ARGV[5] + redis.call('SET', g.currentTokenKey, 'active:' .. g.grantId, 'EX', ARGV[4]) +end +save(g) +return 1 +`; + +const ROTATE = `${COMMON} +local g = read(ARGV[3]) +local marker = redis.call('GET', KEYS[1]) +if marker == 'rotated:' .. ARGV[3] then + remove(ARGV[3]) + return 'reuse' +end +if not g or g.status ~= 'active' or g.currentTokenKey ~= KEYS[1] or + marker ~= 'active:' .. g.grantId then return 'invalid' end +redis.call('SET', KEYS[1], 'rotated:' .. g.grantId, 'KEEPTTL') +redis.call('SET', KEYS[2], 'active:' .. g.grantId, 'EX', g.expiresAt - now) +g.currentTokenKey = KEYS[2] +g.lastTokenAt = now +save(g) +return 'ok' +`; + +function parseToken(token: string): string | null { + const [id, value, extra] = token.split('.'); + return id && + value && + !extra && + /^[a-f0-9]{64}$/.test(id) && + /^[A-Za-z0-9_-]{32,128}$/.test(value) + ? id + : null; +} + +// Existing sessions are self-describing; access JWTs are not. Destructively +// adopt a live refresh family once, preserving its absolute remaining lifetime. +// Listing and refresh use this same atomic operation, never SCAN or SQL. +async function migrateLegacySession( + sessionId: string, + userId?: string, +): Promise { + if (!/^[a-f0-9]{64}$/.test(sessionId)) return; + const raw = await getRedis().get(`mcp-remote-oauth:session:${sessionId}`); + if (!raw) return; + const legacy = JSON.parse(raw) as { + userId: string; + clientId: string; + resource: string; + scopes: string[]; + currentTokenHash: string; + expiresAt: number; + }; + if (userId && legacy.userId !== userId) return; + const clientRaw = await getRedis().get( + `mcp-remote-oauth:client:${legacy.clientId}`, + ); + const client = clientRaw + ? (JSON.parse(clientRaw) as { clientName?: string }) + : null; + const now = nowSeconds(); + const grant = { + ...legacy, + grantId: sessionId, + clientName: safeClientName(client?.clientName), + // Legacy storage has no authorization/activity times. Do not invent them. + authorizedAt: null, + lastUsedAt: null, + lastTokenAt: null, + status: 'active', + redirectUri: '', + codeChallenge: '', + userKey: userKey(legacy.userId), + pairKey: pairKey(legacy.userId, legacy.clientId), + legacyPairKey: legacyPairKey(legacy.userId, legacy.clientId), + legacyUserKey: legacyUserKey(legacy.userId), + currentTokenKey: `${PREFIX}token:${legacy.currentTokenHash}`, + }; + await getRedis().eval( + `${COMMON} +local g = cjson.decode(ARGV[3]) +local legacyKey = 'mcp-remote-oauth:session:' .. g.grantId +if redis.call('GET', legacyKey) ~= ARGV[4] then return 0 end +local ttl = redis.call('TTL', legacyKey) +if ttl <= 0 or g.expiresAt <= now then return 0 end +local legacyTokenKey = 'mcp-remote-oauth:refresh:' .. g.currentTokenHash +if redis.call('GET', legacyTokenKey) ~= 'active:' .. g.grantId then return 0 end +local oldPair = ARGV[5] +if redis.call('EXISTS', g.userKey .. ':legacy-blocked') == 1 or + redis.call('EXISTS', g.pairKey .. ':legacy-blocked') == 1 or + redis.call('EXISTS', g.pairKey) == 1 then + redis.call('DEL', legacyKey, legacyTokenKey) + if redis.call('GET', oldPair) == g.grantId then redis.call('DEL', oldPair) end + return 0 +end +redis.call('ZREMRANGEBYSCORE', g.userKey, '-inf', now) +redis.call('ZREMRANGEBYSCORE', prefix .. 'all', '-inf', now) +if redis.call('ZCARD', g.userKey) >= ${USER_LIMIT} or + redis.call('ZCARD', prefix .. 'all') >= ${GLOBAL_LIMIT} then return 0 end +g.expiresAt = math.min(g.expiresAt, now + ttl) +redis.call('SET', g.currentTokenKey, 'active:' .. g.grantId, 'EX', g.expiresAt - now) +g.currentTokenHash = nil +save(g) +redis.call('DEL', legacyKey, legacyTokenKey) +if redis.call('GET', oldPair) == g.grantId then redis.call('DEL', oldPair) end +return 1`, + 0, + PREFIX, + now, + JSON.stringify(grant), + raw, + legacyPairKey(legacy.userId, legacy.clientId), + ); +} + +function safeClientName(name?: string): string { + return ( + name + ?.replace(/[\u0000-\u001f\u007f-\u009f]/g, '') + .trim() + .slice(0, 200) || 'Unnamed MCP client' + ); +} + +export async function createMcpOAuthCode( + authorization: McpOAuthAuthorization, + clientName?: string, +): Promise { + const code = secret(); + const now = nowSeconds(); + const grant = { + ...authorization, + grantId: secret(), + // Client-supplied text is display-only; never return callback URLs/secrets. + clientName: safeClientName(clientName), + authorizedAt: now, + expiresAt: now + CODE_TTL, + lastUsedAt: null, + lastTokenAt: null, + status: 'pending', + codeKey: codeKey(code), + userKey: userKey(authorization.userId), + pairKey: pairKey(authorization.userId, authorization.clientId), + legacyPairKey: legacyPairKey(authorization.userId, authorization.clientId), + legacyUserKey: legacyUserKey(authorization.userId), + }; + const result = await getRedis().eval( + CREATE, + 0, + PREFIX, + now, + JSON.stringify(grant), + ); + if (result !== 1) throw new Error('MCP authorization capacity reached'); + return code; +} + +async function getGrant(id: string): Promise { + const raw = await getRedis().get(grantKey(id)); + if (!raw) return null; + const grant = JSON.parse(raw) as Grant; + return grant.expiresAt > nowSeconds() ? grant : null; +} + +export async function getMcpOAuthCode(code: string): Promise { + const id = await getRedis().get(codeKey(code)); + return id ? getGrant(id) : null; +} + +export async function exchangeMcpOAuthCode( + code: string, + grant: Grant, + refresh: boolean, +) { + const refreshToken = refresh ? `${grant.grantId}.${secret()}` : undefined; + const result = await getRedis().eval( + EXCHANGE, + 1, + codeKey(code), + PREFIX, + nowSeconds(), + grant.grantId, + refresh ? REFRESH_TTL : ACCESS_TTL, + refreshToken ? tokenKey(refreshToken) : '', + ); + return result === 1 ? { refreshToken } : null; +} + +export async function getMcpOAuthRefresh(token: string): Promise { + const id = parseToken(token); + if (!id) return null; + await migrateLegacySession(id); + const grant = await getGrant(id); + return grant?.currentTokenKey === tokenKey(token) ? grant : null; +} + +export async function rotateMcpOAuthRefresh(token: string, grant: Grant) { + const refreshToken = `${grant.grantId}.${secret()}`; + const status = await getRedis().eval( + ROTATE, + 2, + tokenKey(token), + tokenKey(refreshToken), + PREFIX, + nowSeconds(), + grant.grantId, + ); + return { status, refreshToken }; +} + +export async function revokeMcpOAuthRefresh( + token: string, + clientId?: string, +): Promise { + const id = parseToken(token); + if (!id) return; + await getRedis().eval( + `${COMMON} +local marker = redis.call('GET', KEYS[1]) +local legacyMarker = redis.call('GET', KEYS[2]) +local g = read(ARGV[3]) +if g and (ARGV[4] == '' or g.clientId == ARGV[4]) and + (marker == 'rotated:' .. g.grantId or marker == 'active:' .. g.grantId or + legacyMarker == 'rotated:' .. g.grantId) then + remove(g.grantId) +end +return 1`, + 2, + tokenKey(token), + `mcp-remote-oauth:refresh:${hash(token)}`, + PREFIX, + nowSeconds(), + id, + clientId ?? '', + ); +} + +export async function isMcpOAuthGrantLive(identity: { + userId: string; + clientId?: string; + grantId?: string; +}): Promise { + // Legacy JWTs cannot be targeted safely. Clients must refresh or re-consent. + if (!identity.clientId || !identity.grantId) return false; + const result = await getRedis().eval( + `${COMMON} +local g = read(ARGV[3]) +if not g or g.status ~= 'active' or g.userId ~= ARGV[4] or g.clientId ~= ARGV[5] then return 0 end +g.lastUsedAt = now +save(g) +return 1`, + 0, + PREFIX, + nowSeconds(), + identity.grantId, + identity.userId, + identity.clientId, + ); + return result === 1; +} + +export async function listMcpOAuthGrants( + userId: string, +): Promise { + const clients = await getRedis().zrange( + legacyUserKey(userId), + 0, + USER_LIMIT - 1, + ); + await Promise.all( + clients.map(async (clientId) => { + const id = await getRedis().get(legacyPairKey(userId, clientId)); + if (id) await migrateLegacySession(id, userId); + }), + ); + // One bounded atomic snapshot; expired/code-only grants use the same index. + const rows = (await getRedis().eval( + `${COMMON} +redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now) +local rows = {} +for _, id in ipairs(redis.call('ZRANGE', KEYS[1], 0, -1)) do + local g = read(id) + if g and g.userId == ARGV[3] then + table.insert(rows, cjson.encode({grantId=g.grantId, userId=g.userId, + clientName=g.clientName, authorizedAt=g.authorizedAt, expiresAt=g.expiresAt, + lastUsedAt=g.lastUsedAt, lastTokenAt=g.lastTokenAt, status=g.status})) + else + redis.call('ZREM', KEYS[1], id) + redis.call('ZREM', prefix .. 'all', id) + end +end +return rows`, + 1, + userKey(userId), + PREFIX, + nowSeconds(), + userId, + )) as string[]; + return rows.map((row) => JSON.parse(row) as McpOAuthGrantSummary); +} + +/** Authorization of the target user belongs to the authenticated caller. */ +export async function revokeMcpOAuthGrants( + userId: string, + grantId?: string, +): Promise { + const legacyPairs = grantId + ? [] + : (await getRedis().zrange(legacyUserKey(userId), 0, USER_LIMIT - 1)).map( + (id) => legacyPairKey(userId, id), + ); + await getRedis().eval( + `${COMMON} +if ARGV[4] ~= '' then + local g = read(ARGV[4]) + if g and g.userId == ARGV[3] then remove(g.grantId) end +else + redis.call('SET', KEYS[1] .. ':legacy-blocked', '1', 'EX', ${31 * 24 * 3600}) + for _, pair in ipairs(cjson.decode(ARGV[5])) do removeLegacy(pair, ARGV[3]) end + redis.call('DEL', KEYS[2]) + for _, id in ipairs(redis.call('ZRANGE', KEYS[1], 0, -1)) do + local g = read(id) + if g and g.userId == ARGV[3] then remove(id) end + end + redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now) +end +return 1`, + 2, + userKey(userId), + legacyUserKey(userId), + PREFIX, + nowSeconds(), + userId, + grantId ?? '', + JSON.stringify(legacyPairs), + ); +} diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index 03bbc37d36..6e1119667a 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -98,6 +98,8 @@ export const mcpAccessTokenPayloadSchema = z.object({ error: () => ({ message: 'Token type must be "mcp"' }), }), s: z.array(z.string().min(1)).min(1, 'At least one scope is required'), + c: z.string().min(1), + g: z.string().min(1), }), }); @@ -105,6 +107,8 @@ export type McpAccessTokenPayload = z.infer; export interface McpAccessTokenContext { userId: string; + clientId?: string; + grantId?: string; tokenType: 'mcp'; version: number; resource: string;