From 8d833bb12e837db45cffa5208598f6dc42948fb3 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Wed, 29 Jul 2026 22:54:17 +0300 Subject: [PATCH] Add MCP authorization HTTP flow Add service-issued signed authorization tickets, human consent endpoints, one-time delegation redemption, active grant resolution, and revocation. Revalidate current account and agent-management authority on every use without accepting or copying agent API credentials. --- .../0023_mcp_authorization_source_truth.sql | 42 ++ scripts/orbit-d1-test-worker.ts | 16 + scripts/orbit-mcp-authorization-tests.ts | 30 ++ scripts/orbit-slice1-tests.ts | 271 ++++++++++- src/server/http/api.ts | 456 +++++++++++++++++- src/server/identity/bindings.ts | 2 + .../identity/mcp-authorization-ticket.ts | 124 +++++ src/server/observability/telemetry.ts | 8 +- .../d1/d1-mcp-authorization-repository.ts | 23 + wrangler.slice1-test.jsonc | 4 +- 10 files changed, 970 insertions(+), 6 deletions(-) create mode 100644 migrations/0023_mcp_authorization_source_truth.sql create mode 100644 src/server/identity/mcp-authorization-ticket.ts diff --git a/migrations/0023_mcp_authorization_source_truth.sql b/migrations/0023_mcp_authorization_source_truth.sql new file mode 100644 index 0000000..276d6fe --- /dev/null +++ b/migrations/0023_mcp_authorization_source_truth.sql @@ -0,0 +1,42 @@ +PRAGMA foreign_keys = ON; + +DROP TRIGGER IF EXISTS mcp_delegation_redemptions_validate; + +CREATE TRIGGER mcp_delegation_redemptions_validate +BEFORE INSERT ON mcp_delegation_redemptions +BEGIN + SELECT RAISE(ABORT, 'invalid_mcp_delegation_code') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_delegation_codes code + JOIN mcp_authorization_grants grant_row + ON grant_row.id = code.grant_id + JOIN accounts account + ON account.id = grant_row.account_id + WHERE code.id = NEW.code_id + AND code.grant_id = NEW.grant_id + AND code.authorization_request_id = NEW.authorization_request_id + AND code.consumed_at IS NULL + AND code.expires_at > NEW.redeemed_at + AND grant_row.revoked_at IS NULL + AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.redeemed_at) + AND account.status = 'active' + AND ( + EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = grant_row.agent_id + AND membership.account_id = grant_row.account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = grant_row.account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) + ); +END; diff --git a/scripts/orbit-d1-test-worker.ts b/scripts/orbit-d1-test-worker.ts index f23e21c..cf6c930 100644 --- a/scripts/orbit-d1-test-worker.ts +++ b/scripts/orbit-d1-test-worker.ts @@ -346,6 +346,22 @@ async function handleAction(body: ActionRequest, env: Environment): Promise { ); }); + test('losing current agent management invalidates touch and redemption', async () => { + const now = Date.now(); + const accountId = 'mcp-source-truth-sponsor'; + const agentId = 'mcp-source-truth-agent'; + await callAction('seedMcpAgent', { accountId, agentId, now }); + const input = grantData('mcp-source-truth', accountId, agentId, now + 1); + await callAction('createMcpGrant', input, 201); + await callAction('revokeMcpMembership', { + accountId, + agentId, + now: now + 2, + }); + + const touched = await callAction<{ touched: boolean }>( + 'touchMcpGrant', + { grantId: input.grantId, now: now + 3 }, + ); + assert.equal(touched.touched, false); + + const redemption = await callAction<{ error: string }>('redeemMcpCode', { + codeId: input.codeId, + grantId: input.grantId, + authorizationRequestId: input.authorizationRequestId, + auditEventId: 'mcp-source-truth-redeem-audit', + requestId: 'mcp-source-truth-redeem-request', + now: now + 4, + }, 409); + assert.match(redemption.error, /invalid_mcp_delegation_code/u); + }); + test('durable grants and revocations enter backups without transient codes', async () => { const backup = await callAction<{ schemaVersion: number; diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index fff0c58..5c535e3 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -23,6 +23,7 @@ const CONFIG = 'wrangler.slice1-test.jsonc'; const ORIGIN = 'http://localhost:4321'; const NOW = 1_784_103_600_000; const INVITATION_PEPPER = 'test-invitation-pepper-at-least-32-bytes-long'; +const MCP_SERVICE_SECRET = 'test-mcp-service-secret-at-least-32-bytes-long'; let persistDirectory = ''; let baseUrl = ''; @@ -222,9 +223,12 @@ let firstCredentialToken = ''; let replacementCredentialId = ''; let recoveredCredentialId = ''; let sponsoredAgentEtag = ''; + let mcpGrantId = ''; + let mcpDelegationCode = ''; + const mcpAuthorizationRequestId = 'chatgpt-authorization-request-001'; test('token families use a 128-bit selector and 256-bit secret', async () => { - for (const family of ['invitation', 'session', 'agent', 'registration'] as const) { + for (const family of ['invitation', 'session', 'agent', 'registration', 'delegation'] as const) { const generated = await createOpaqueToken(family, `${family}-pepper-at-least-32-random-bytes`); const parsed = parseOpaqueToken(generated.token); assert.equal(parsed?.family, family); @@ -457,6 +461,256 @@ let firstCredentialToken = ''; assert.deepEqual(meBody.sponsoredAgents.map((agent) => agent.id), [sponsoredAgentId]); }); + test('MCP authorization uses CSRF, least privilege, one-time exchange and revocation', async () => { + const ticketRequest = { + authorizationRequestId: mcpAuthorizationRequestId, + oauthClientId: 'chatgpt-dynamic-client-001', + oauthClientLabel: 'ChatGPT', + scopes: ['feed:read'], + }; + + const unauthenticatedList = await request('/v1/mcp/authorizations', {}, NOW + 47); + assert.equal(unauthenticatedList.status, 401); + + const noServiceTicket = await postJson( + '/v1/mcp/authorization-tickets', + ticketRequest, + {}, + NOW + 47, + ); + assert.equal(noServiceTicket.status, 401); + + const elevated = await postJson( + '/v1/mcp/authorization-tickets', + { ...ticketRequest, scopes: ['feed:read', 'records:write'] }, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + NOW + 48, + ); + assert.equal(elevated.status, 400); + const elevatedBody = await elevated.json() as { error: { code: string } }; + assert.equal(elevatedBody.error.code, 'invalid_mcp_authorization_scope'); + + const ticketResponse = await postJson( + '/v1/mcp/authorization-tickets', + ticketRequest, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + NOW + 49, + ); + assert.equal(ticketResponse.status, 201, await ticketResponse.clone().text()); + const ticketBody = await ticketResponse.json() as { + ticket: string; + authorizationRequest: { + id: string; + oauthClient: { id: string; label: string }; + scopes: string[]; + issuedAt: number; + expiresAt: number; + }; + }; + assert.ok(ticketBody.ticket.startsWith('orb_mcp_auth_v1.')); + assert.equal(ticketBody.authorizationRequest.id, mcpAuthorizationRequestId); + assert.equal(ticketBody.authorizationRequest.oauthClient.label, 'ChatGPT'); + assert.deepEqual(ticketBody.authorizationRequest.scopes, ['feed:read']); + assert.equal(ticketBody.authorizationRequest.issuedAt, NOW + 49); + assert.equal(ticketBody.authorizationRequest.expiresAt, NOW + 49 + 10 * 60 * 1000); + + const unauthenticatedInspect = await postJson( + '/v1/mcp/authorization-tickets/inspect', + { ticket: ticketBody.ticket }, + {}, + NOW + 49, + ); + assert.equal(unauthenticatedInspect.status, 401); + + const inspected = await postJson( + '/v1/mcp/authorization-tickets/inspect', + { ticket: ticketBody.ticket }, + { cookie: cookieHeader(sponsorCookies) }, + NOW + 49, + ); + assert.equal(inspected.status, 200, await inspected.clone().text()); + const inspectedBody = await inspected.json() as { + authorizationRequest: { id: string; oauthClient: { label: string }; scopes: string[] }; + }; + assert.equal(inspectedBody.authorizationRequest.id, mcpAuthorizationRequestId); + assert.equal(inspectedBody.authorizationRequest.oauthClient.label, 'ChatGPT'); + assert.deepEqual(inspectedBody.authorizationRequest.scopes, ['feed:read']); + + const tamperedTicket = `${ticketBody.ticket.slice(0, -1)}${ticketBody.ticket.endsWith('A') ? 'B' : 'A'}`; + const tamperedInspect = await postJson( + '/v1/mcp/authorization-tickets/inspect', + { ticket: tamperedTicket }, + { cookie: cookieHeader(sponsorCookies) }, + NOW + 49, + ); + assert.equal(tamperedInspect.status, 400); + + const authorizationBody = { + agentId: sponsoredAgentId, + ticket: ticketBody.ticket, + }; + const noCsrf = await postJson( + '/v1/mcp/authorizations', + authorizationBody, + { cookie: cookieHeader(sponsorCookies), origin: ORIGIN }, + NOW + 50, + ); + assert.equal(noCsrf.status, 403); + + const tamperedCreate = await postJson( + '/v1/mcp/authorizations', + { agentId: sponsoredAgentId, ticket: tamperedTicket }, + authenticatedHeaders(sponsorCookies, true), + NOW + 50, + ); + assert.equal(tamperedCreate.status, 400); + + const created = await postJson( + '/v1/mcp/authorizations', + authorizationBody, + authenticatedHeaders(sponsorCookies, true), + NOW + 50, + ); + assert.equal(created.status, 201, await created.clone().text()); + const createdBody = await created.json() as { + authorization: { + id: string; + accountId: string; + agent: { id: string; handle: string }; + scopes: string[]; + oauthClient: { id: string; label: string }; + status: string; + createdAt: number; + expiresAt: number; + }; + delegation: { + code: string; + authorizationRequestId: string; + expiresAt: number; + }; + }; + mcpGrantId = createdBody.authorization.id; + mcpDelegationCode = createdBody.delegation.code; + assert.ok(mcpGrantId); + assert.ok(mcpDelegationCode.startsWith('orb_mcp_v1_')); + assert.deepEqual(createdBody.authorization.scopes, ['feed:read']); + assert.equal(createdBody.authorization.agent.id, sponsoredAgentId); + assert.equal(createdBody.authorization.oauthClient.label, 'ChatGPT'); + assert.equal(createdBody.authorization.status, 'active'); + assert.equal(createdBody.delegation.authorizationRequestId, mcpAuthorizationRequestId); + assert.equal(createdBody.delegation.expiresAt, NOW + 50 + 5 * 60 * 1000); + assert.equal(createdBody.authorization.expiresAt, NOW + 50 + 90 * 24 * 60 * 60 * 1000); + + const listed = await request('/v1/mcp/authorizations', { + headers: authenticatedHeaders(sponsorCookies), + }, NOW + 51); + assert.equal(listed.status, 200, await listed.clone().text()); + const listedText = await listed.text(); + assert.ok(!listedText.includes(mcpDelegationCode)); + assert.ok(!listedText.includes('secretDigest')); + const listedBody = JSON.parse(listedText) as { + authorizations: Array<{ id: string; scopes: string[] }>; + }; + assert.deepEqual( + listedBody.authorizations.map((authorization) => authorization.id), + [mcpGrantId], + ); + + const noServiceCredential = await postJson('/v1/mcp/delegations/redeem', { + code: mcpDelegationCode, + authorizationRequestId: mcpAuthorizationRequestId, + }, {}, NOW + 52); + assert.equal(noServiceCredential.status, 401); + + const wrongRequest = await postJson('/v1/mcp/delegations/redeem', { + code: mcpDelegationCode, + authorizationRequestId: 'different-authorization-request', + }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 53); + assert.equal(wrongRequest.status, 400); + + const redeemed = await postJson('/v1/mcp/delegations/redeem', { + code: mcpDelegationCode, + authorizationRequestId: mcpAuthorizationRequestId, + }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 54); + assert.equal(redeemed.status, 200, await redeemed.clone().text()); + const redeemedBody = await redeemed.json() as { + authorization: { id: string; scopes: string[]; status: string }; + }; + assert.equal(redeemedBody.authorization.id, mcpGrantId); + assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read']); + assert.equal(redeemedBody.authorization.status, 'active'); + + const replay = await postJson('/v1/mcp/delegations/redeem', { + code: mcpDelegationCode, + authorizationRequestId: mcpAuthorizationRequestId, + }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 55); + assert.equal(replay.status, 400); + + const resolved = await postJson( + `/v1/mcp/grants/${encodeURIComponent(mcpGrantId)}/resolve`, + {}, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + NOW + 56, + ); + assert.equal(resolved.status, 200, await resolved.clone().text()); + const resolvedBody = await resolved.json() as { + authorization: { id: string; lastUsedAt: number }; + account: { handle: string }; + agent: { id: string; handle: string; status: string }; + }; + assert.equal(resolvedBody.authorization.id, mcpGrantId); + assert.equal(resolvedBody.authorization.lastUsedAt, NOW + 56); + assert.equal(resolvedBody.agent.id, sponsoredAgentId); + assert.equal(resolvedBody.agent.handle, 'selene-test-agent'); + assert.equal(resolvedBody.agent.status, 'active'); + + const revokeNoCsrf = await postJson( + `/v1/mcp/authorizations/${encodeURIComponent(mcpGrantId)}/revoke`, + {}, + { cookie: cookieHeader(sponsorCookies), origin: ORIGIN }, + NOW + 57, + ); + assert.equal(revokeNoCsrf.status, 403); + + const revoked = await postJson( + `/v1/mcp/authorizations/${encodeURIComponent(mcpGrantId)}/revoke`, + {}, + authenticatedHeaders(sponsorCookies, true), + NOW + 58, + ); + assert.equal(revoked.status, 200, await revoked.clone().text()); + const revokedBody = await revoked.json() as { + authorization: { status: string; revokedAt: number; revokedReason: string }; + }; + assert.equal(revokedBody.authorization.status, 'revoked'); + assert.equal(revokedBody.authorization.revokedAt, NOW + 58); + assert.equal(revokedBody.authorization.revokedReason, 'user_revoked'); + + const resolvedAfterRevoke = await postJson( + `/v1/mcp/grants/${encodeURIComponent(mcpGrantId)}/resolve`, + {}, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + NOW + 59, + ); + assert.equal(resolvedAfterRevoke.status, 401); + + const secondRevoke = await postJson( + `/v1/mcp/authorizations/${encodeURIComponent(mcpGrantId)}/revoke`, + {}, + authenticatedHeaders(sponsorCookies, true), + NOW + 60, + ); + assert.equal(secondRevoke.status, 409); + + const expiredTicket = await postJson( + '/v1/mcp/authorization-tickets/inspect', + { ticket: ticketBody.ticket }, + { cookie: cookieHeader(sponsorCookies) }, + NOW + 49 + 10 * 60 * 1000, + ); + assert.equal(expiredTicket.status, 400); + }); + test('public and management profiles expose bounded fields without credential secrets', async () => { const publicResponse = await request('/v1/agents/selene-test-agent', {}, NOW + 45); assert.equal(publicResponse.status, 200); @@ -625,6 +879,21 @@ let firstCredentialToken = ''; }, authenticatedHeaders(otherSponsorCookies, true), NOW + 53); assert.equal(rotated.status, 404); + + const foreignTicketResponse = await postJson('/v1/mcp/authorization-tickets', { + authorizationRequestId: 'foreign-authorization-request', + oauthClientId: 'foreign-client', + oauthClientLabel: 'Foreign client', + scopes: ['feed:read'], + }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 54); + assert.equal(foreignTicketResponse.status, 201); + const foreignTicket = (await foreignTicketResponse.json() as { ticket: string }).ticket; + + const delegated = await postJson('/v1/mcp/authorizations', { + agentId: sponsoredAgentId, + ticket: foreignTicket, + }, authenticatedHeaders(otherSponsorCookies, true), NOW + 54); + assert.equal(delegated.status, 404); }); test('only platform owner can apply all three publication policies', async () => { diff --git a/src/server/http/api.ts b/src/server/http/api.ts index 2344fd1..9c9362e 100644 --- a/src/server/http/api.ts +++ b/src/server/http/api.ts @@ -17,6 +17,10 @@ import { assertIdentityBindings, type OrbitBindings } from '../identity/bindings import { clearHostCookie, readCookie, serializeHostCookie } from '../identity/cookies'; import { GithubClient } from '../identity/github'; import { createOAuthMaterial, parseOAuthCookie, parseOAuthState } from '../identity/oauth'; +import { + createMcpAuthorizationTicket, + verifyMcpAuthorizationTicket, +} from '../identity/mcp-authorization-ticket'; import { createOpaqueToken, hmacDigest, @@ -32,6 +36,7 @@ import { D1PublicationRepository } from '../repositories/d1/d1-publication-repos import { D1PlatformRepository } from '../repositories/d1/d1-platform-repository'; import { D1DirectMessageRepository } from '../repositories/d1/d1-direct-message-repository'; import { D1MediaRepository } from '../repositories/d1/d1-media-repository'; +import { D1McpAuthorizationRepository } from '../repositories/d1/d1-mcp-authorization-repository'; import { cursorFilterDigest, decodeAgentRecordCursor, @@ -96,6 +101,10 @@ import { utcMonth, } from '../media/media-service'; import type { MediaRepository } from '../repositories/media-repository'; +import type { + McpAuthorizationGrantView, + McpAuthorizationRepository, +} from '../repositories/mcp-authorization-repository'; import { agentApiContract } from '../../data/agentApiContract'; export interface ApiDependencies { @@ -128,6 +137,10 @@ const MAX_PUBLIC_SEARCH_TERMS = 8; const IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; const CREDENTIAL_ACTIVITY_BUCKET_MS = 15 * 60 * 1000; const REGISTRATION_CODE_TTL_MS = 10 * 60 * 1000; +const MCP_AUTHORIZATION_TICKET_TTL_MS = 10 * 60 * 1000; +const MCP_DELEGATION_CODE_TTL_MS = 5 * 60 * 1000; +const MCP_AUTHORIZATION_GRANT_TTL_MS = 90 * 24 * 60 * 60 * 1000; +const MCP_AUTHORIZATION_SCOPE = 'feed:read' as const; class ApiError extends Error { readonly status: number; @@ -730,9 +743,13 @@ function requirePublicationReviewer(auth: AuthenticatedHuman): void { } } +function accountCanManageAgent(account: AccountView, agent: ManagedAgentView): boolean { + return account.roles.includes('platform_owner') + || agent.primarySponsorAccountId === account.id; +} + function canManageAgent(auth: AuthenticatedHuman, agent: ManagedAgentView): boolean { - return auth.account.roles.includes('platform_owner') - || agent.primarySponsorAccountId === auth.account.id; + return accountCanManageAgent(auth.account, agent); } function requireAgentManagement(auth: AuthenticatedHuman, agent: ManagedAgentView | null): ManagedAgentView { @@ -2877,6 +2894,157 @@ function publicInvitation(invitation: InvitationRow, now: number) { }; } +function mcpConfigurationValue(value: string | undefined): string { + if (typeof value !== 'string' || value.length < 32) { + throw new ApiError( + 503, + 'mcp_authorization_unavailable', + 'Orbit MCP authorization is not configured.', + ); + } + return value; +} + +function authenticateMcpService(request: Request, env: OrbitBindings): void { + const expected = mcpConfigurationValue(env.ORBIT_MCP_SERVICE_SECRET_V1); + const authorization = request.headers.get('authorization'); + const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : ''; + if (!timingSafeEqual(token, expected)) { + throw new ApiError( + 401, + 'mcp_service_authentication_required', + 'A valid Orbit MCP service credential is required.', + {}, + { 'www-authenticate': 'Bearer' }, + ); + } +} + +function mcpAuthorizationString( + value: unknown, + field: string, + maximumCodePoints: number, +): string { + if (typeof value !== 'string') { + throw new ApiError( + 400, + 'invalid_mcp_authorization', + `${field} must be a string.`, + ); + } + const normalized = value.trim(); + const length = [...normalized].length; + if (length === 0 || length > maximumCodePoints) { + throw new ApiError( + 400, + 'invalid_mcp_authorization', + `${field} is outside its allowed length.`, + ); + } + return normalized; +} + +function mcpAuthorizationScopes(value: unknown): [typeof MCP_AUTHORIZATION_SCOPE] { + if ( + !Array.isArray(value) + || value.length !== 1 + || value[0] !== MCP_AUTHORIZATION_SCOPE + ) { + throw new ApiError( + 400, + 'invalid_mcp_authorization_scope', + 'v0.2A grants require exactly the feed:read scope.', + ); + } + return [MCP_AUTHORIZATION_SCOPE]; +} + +function mcpGrantStatus( + grant: McpAuthorizationGrantView, + now: number, +): 'active' | 'expired' | 'revoked' { + if (grant.revokedAt !== null) return 'revoked'; + if (grant.expiresAt !== null && grant.expiresAt <= now) return 'expired'; + return 'active'; +} + +function mcpGrantResponse(grant: McpAuthorizationGrantView, now: number) { + return { + id: grant.id, + accountId: grant.accountId, + agent: { + id: grant.agentId, + handle: grant.handle, + }, + scopes: grant.scopes, + oauthClient: { + id: grant.oauthClientId, + label: grant.oauthClientLabel, + }, + status: mcpGrantStatus(grant, now), + createdAt: grant.createdAt, + lastUsedAt: grant.lastUsedAt, + expiresAt: grant.expiresAt, + revokedAt: grant.revokedAt, + revokedReason: grant.revokedReason, + }; +} + +async function resolveActiveMcpGrant( + grantId: string, + identityRepository: IdentityRepository, + agentRepository: AgentRepository, + mcpRepository: McpAuthorizationRepository, + now: number, + touch: boolean, +): Promise<{ + grant: McpAuthorizationGrantView; + account: AccountView; + agent: ManagedAgentView; +}> { + const grant = await mcpRepository.getGrant(grantId); + if ( + !grant + || mcpGrantStatus(grant, now) !== 'active' + || !grant.scopes.includes(MCP_AUTHORIZATION_SCOPE) + ) { + throw new ApiError( + 401, + 'mcp_authorization_invalid', + 'The Orbit MCP authorization is expired, revoked, or unavailable.', + {}, + { 'www-authenticate': 'Bearer' }, + ); + } + const account = await identityRepository.getAccount(grant.accountId); + const agent = await agentRepository.getManagedAgent(grant.agentId); + if (!account || !agent || !accountCanManageAgent(account, agent)) { + throw new ApiError( + 401, + 'mcp_authorization_invalid', + 'The Orbit MCP authorization no longer has access to this agent.', + {}, + { 'www-authenticate': 'Bearer' }, + ); + } + if (touch && !await mcpRepository.touchGrant({ grantId, usedAt: now })) { + if (grant.lastUsedAt !== now) { + throw new ApiError( + 401, + 'mcp_authorization_invalid', + 'The Orbit MCP authorization changed before it could be used.', + {}, + { 'www-authenticate': 'Bearer' }, + ); + } + } + return { + grant: touch ? { ...grant, lastUsedAt: now } : grant, + account, + agent, + }; +} + export async function handleApiRequest( request: Request, env: OrbitBindings, @@ -2893,6 +3061,7 @@ export async function handleApiRequest( const platformRepository: PlatformRepository = new D1PlatformRepository(env.DB); const directMessageRepository: DirectMessageRepository = new D1DirectMessageRepository(env.DB); const mediaRepository: MediaRepository = new D1MediaRepository(env.DB); + const mcpRepository: McpAuthorizationRepository = new D1McpAuthorizationRepository(env.DB); const github = new GithubClient({ clientId: env.GITHUB_OAUTH_CLIENT_ID, clientSecret: env.GITHUB_OAUTH_CLIENT_SECRET, @@ -2905,6 +3074,131 @@ export async function handleApiRequest( return json(agentApiContract); } + if (request.method === 'POST' && path === '/v1/mcp/authorization-tickets') { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields( + body, + ['authorizationRequestId', 'oauthClientId', 'oauthClientLabel', 'scopes'], + 'invalid_mcp_authorization_ticket_fields', + ); + const authorizationRequestId = mcpAuthorizationString( + body.authorizationRequestId, + 'authorizationRequestId', + 200, + ); + const oauthClientId = mcpAuthorizationString(body.oauthClientId, 'oauthClientId', 255); + const oauthClientLabel = mcpAuthorizationString( + body.oauthClientLabel, + 'oauthClientLabel', + 120, + ); + const scopes = mcpAuthorizationScopes(body.scopes); + const expiresAt = now + MCP_AUTHORIZATION_TICKET_TTL_MS; + const ticket = await createMcpAuthorizationTicket({ + authorizationRequestId, + oauthClientId, + oauthClientLabel, + scopes, + issuedAt: now, + expiresAt, + }, mcpConfigurationValue(env.ORBIT_MCP_SERVICE_SECRET_V1)); + return json({ + ticket, + authorizationRequest: { + id: authorizationRequestId, + oauthClient: { id: oauthClientId, label: oauthClientLabel }, + scopes, + issuedAt: now, + expiresAt, + }, + }, 201); + } + + if (request.method === 'POST' && path === '/v1/mcp/delegations/redeem') { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields( + body, + ['code', 'authorizationRequestId'], + 'invalid_mcp_delegation_fields', + ); + const token = mcpAuthorizationString(body.code, 'code', 160); + const authorizationRequestId = mcpAuthorizationString( + body.authorizationRequestId, + 'authorizationRequestId', + 200, + ); + const parsed = parseOpaqueToken(token); + const code = parsed?.family === 'delegation' + ? await mcpRepository.getDelegationCode(parsed.selector) + : null; + const pepper = mcpConfigurationValue(env.ORBIT_MCP_DELEGATION_PEPPER_V1); + const verified = code + ? await verifyOpaqueToken(token, 'delegation', code.secretDigest, pepper) + : null; + if ( + !code + || !verified + || code.authorizationRequestId !== authorizationRequestId + || code.consumedAt !== null + || code.expiresAt <= now + ) { + throw new ApiError( + 400, + 'invalid_mcp_delegation_code', + 'The Orbit MCP delegation code is invalid, expired, or already used.', + ); + } + const grant = await mcpRepository.redeemDelegationCode({ + codeId: code.id, + grantId: code.grantId, + authorizationRequestId, + redemptionAuditEventId: createEntityId(), + requestId, + redeemedAt: now, + }); + await resolveActiveMcpGrant( + grant.id, + repository, + agentRepository, + mcpRepository, + now, + false, + ); + return json({ authorization: mcpGrantResponse(grant, now) }); + } + + const mcpResolveMatch = /^\/v1\/mcp\/grants\/([^/]+)\/resolve$/u.exec(path); + if (request.method === 'POST' && mcpResolveMatch) { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields(body, [], 'invalid_mcp_resolve_fields'); + const grantId = decodeURIComponent(mcpResolveMatch[1]); + const resolved = await resolveActiveMcpGrant( + grantId, + repository, + agentRepository, + mcpRepository, + now, + true, + ); + return json({ + authorization: mcpGrantResponse(resolved.grant, now), + account: { + id: resolved.account.id, + handle: resolved.account.handle, + }, + agent: { + id: resolved.agent.id, + handle: resolved.agent.handle, + status: resolved.agent.status, + onboardingState: resolved.agent.onboardingState, + publicationMode: resolved.agent.publicationMode, + }, + }); + } + const mediaReadMatch = /^\/v1\/media\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/u.exec(path); if ((request.method === 'GET' || request.method === 'HEAD') && mediaReadMatch) { return await serveMedia( @@ -3632,6 +3926,149 @@ export async function handleApiRequest( return json({ moderation: { id: reversalActionId, status: 'reversed' } }); } + if (request.method === 'POST' && path === '/v1/mcp/authorization-tickets/inspect') { + await authenticateHuman(request, env, repository, now, false); + const body = await readJson(request); + requireExactFields(body, ['ticket'], 'invalid_mcp_authorization_ticket_fields'); + const ticket = mcpAuthorizationString(body.ticket, 'ticket', 1600); + const authorizationRequest = await verifyMcpAuthorizationTicket( + ticket, + mcpConfigurationValue(env.ORBIT_MCP_SERVICE_SECRET_V1), + now, + ); + if (!authorizationRequest) { + throw new ApiError( + 400, + 'invalid_mcp_authorization_ticket', + 'The Orbit MCP authorization ticket is invalid or expired.', + ); + } + return json({ + authorizationRequest: { + id: authorizationRequest.authorizationRequestId, + oauthClient: { + id: authorizationRequest.oauthClientId, + label: authorizationRequest.oauthClientLabel, + }, + scopes: authorizationRequest.scopes, + issuedAt: authorizationRequest.issuedAt, + expiresAt: authorizationRequest.expiresAt, + }, + }); + } + + if (request.method === 'GET' && path === '/v1/mcp/authorizations') { + const auth = await authenticateHuman(request, env, repository, now, false); + const grants = await mcpRepository.listAccountGrants(auth.account.id); + return json({ authorizations: grants.map((grant) => mcpGrantResponse(grant, now)) }); + } + + if (request.method === 'POST' && path === '/v1/mcp/authorizations') { + const auth = await authenticateHuman(request, env, repository, now, true); + const body = await readJson(request); + requireExactFields( + body, + ['agentId', 'ticket'], + 'invalid_mcp_authorization_fields', + ); + const ticket = mcpAuthorizationString(body.ticket, 'ticket', 1600); + const authorizationRequest = await verifyMcpAuthorizationTicket( + ticket, + mcpConfigurationValue(env.ORBIT_MCP_SERVICE_SECRET_V1), + now, + ); + if (!authorizationRequest) { + throw new ApiError( + 400, + 'invalid_mcp_authorization_ticket', + 'The Orbit MCP authorization ticket is invalid or expired.', + ); + } + const agentId = mcpAuthorizationString(body.agentId, 'agentId', 100); + const agent = requireAgentManagement( + auth, + await agentRepository.getManagedAgent(agentId), + ); + const { + scopes, + oauthClientId, + oauthClientLabel, + authorizationRequestId, + } = authorizationRequest; + const pepper = mcpConfigurationValue(env.ORBIT_MCP_DELEGATION_PEPPER_V1); + const code = await createOpaqueToken('delegation', pepper); + const grantId = createEntityId(); + const codeExpiresAt = now + MCP_DELEGATION_CODE_TTL_MS; + const grantExpiresAt = now + MCP_AUTHORIZATION_GRANT_TTL_MS; + await mcpRepository.createGrantWithCode({ + grant: { + id: grantId, + accountId: auth.account.id, + agentId: agent.id, + scopes, + oauthClientId, + oauthClientLabel, + createdAt: now, + expiresAt: grantExpiresAt, + }, + code: { + id: code.selector, + secretDigest: code.digest, + hashVersion: code.hashVersion, + grantId, + authorizationRequestId, + createdAt: now, + expiresAt: codeExpiresAt, + consumedAt: null, + }, + auditEventId: createEntityId(), + requestId, + }); + const grant = await mcpRepository.getGrant(grantId); + if (!grant) throw new Error('mcp_authorization_grant_missing_after_creation'); + return json({ + authorization: mcpGrantResponse(grant, now), + delegation: { + code: code.token, + authorizationRequestId, + expiresAt: codeExpiresAt, + }, + }, 201); + } + + const mcpAuthorizationRevokeMatch = /^\/v1\/mcp\/authorizations\/([^/]+)\/revoke$/u.exec(path); + if (request.method === 'POST' && mcpAuthorizationRevokeMatch) { + const auth = await authenticateHuman(request, env, repository, now, true); + const body = await readJson(request); + requireExactFields(body, [], 'invalid_mcp_authorization_revoke_fields'); + const grantId = decodeURIComponent(mcpAuthorizationRevokeMatch[1]); + const grant = await mcpRepository.getGrant(grantId); + const agent = grant ? await agentRepository.getManagedAgent(grant.agentId) : null; + if ( + !grant + || ( + grant.accountId !== auth.account.id + && (!agent || !accountCanManageAgent(auth.account, agent)) + ) + ) { + throw new ApiError(404, 'mcp_authorization_not_found', 'MCP authorization was not found.'); + } + if (grant.revokedAt !== null) { + throw new ApiError(409, 'mcp_authorization_already_revoked', 'MCP authorization is already revoked.'); + } + await mcpRepository.revokeGrant({ + grantId, + actorAccountId: auth.account.id, + reason: 'user_revoked', + auditEventId: createEntityId(), + requestId, + revokedAt: now, + }); + const revoked = await mcpRepository.getGrant(grantId); + if (!revoked) throw new Error('mcp_authorization_grant_missing_after_revocation'); + return json({ authorization: mcpGrantResponse(revoked, now) }); + } + if (request.method === 'POST' && path === '/v1/agent-registration-codes') { const auth = await authenticateHuman(request, env, repository, now, true); return await handleCreateRegistrationCode(request, env, agentRepository, auth, now, requestId); @@ -3879,6 +4316,21 @@ export async function handleApiRequest( requestId, ), 403); } + if (/invalid_mcp_delegation_code/u.test(message)) { + return json(createErrorEnvelope( + 'invalid_mcp_delegation_code', + 'The Orbit MCP delegation code is invalid, expired, or already used.', + requestId, + ), 400); + } + if (/mcp_authorization_(?:grant_unavailable|agent_not_manageable|revoke_forbidden|grant_identity_immutable)|mcp_delegation_code_identity_immutable|UNIQUE constraint failed:\s*mcp_/iu.test(message)) { + return json(createErrorEnvelope( + 'mcp_authorization_state_conflict', + 'The Orbit MCP authorization changed before the request completed.', + requestId, + recoveryDetails(false, 'restart_authorization', null), + ), 409); + } if (error instanceof MediaServiceError) { return json(createErrorEnvelope(error.code, 'The media request could not be completed.', requestId), error.status); } diff --git a/src/server/identity/bindings.ts b/src/server/identity/bindings.ts index 41ff00e..ef98997 100644 --- a/src/server/identity/bindings.ts +++ b/src/server/identity/bindings.ts @@ -90,6 +90,8 @@ export interface OrbitBindings { ORBIT_OAUTH_STATE_PEPPER_V1: string; ORBIT_CSRF_PEPPER_V1: string; ORBIT_CURSOR_PEPPER_V1: string; + ORBIT_MCP_DELEGATION_PEPPER_V1?: string; + ORBIT_MCP_SERVICE_SECRET_V1?: string; ORBIT_BACKUP_ENCRYPTION_KEY_V1?: string; ORBIT_BACKUP_ENABLED?: string; ORBIT_MEDIA_ENABLED?: string; diff --git a/src/server/identity/mcp-authorization-ticket.ts b/src/server/identity/mcp-authorization-ticket.ts new file mode 100644 index 0000000..94bd888 --- /dev/null +++ b/src/server/identity/mcp-authorization-ticket.ts @@ -0,0 +1,124 @@ +import { hmacDigest, timingSafeEqual } from './tokens'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const PREFIX = 'orb_mcp_auth_v1'; +const MAX_TTL_MS = 10 * 60 * 1000; +const CLOCK_SKEW_MS = 60 * 1000; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u; + +export interface McpAuthorizationTicketPayload { + authorizationRequestId: string; + oauthClientId: string; + oauthClientLabel: string; + scopes: ['feed:read']; + issuedAt: number; + expiresAt: number; +} + +function base64UrlEncode(value: string): string { + const bytes = encoder.encode(value); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, ''); +} + +function base64UrlDecode(value: string): string | null { + if (!BASE64URL_PATTERN.test(value)) return null; + try { + const base64 = value.replaceAll('-', '+').replaceAll('_', '/'); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '='); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return decoder.decode(bytes); + } catch { + return null; + } +} + +function boundedString(value: unknown, maximumCodePoints: number): value is string { + return typeof value === 'string' + && [...value].length > 0 + && [...value].length <= maximumCodePoints; +} + +function isPayload(value: unknown): value is McpAuthorizationTicketPayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.join(',') !== [ + 'authorizationRequestId', + 'expiresAt', + 'issuedAt', + 'oauthClientId', + 'oauthClientLabel', + 'scopes', + ].sort().join(',')) return false; + return boundedString(record.authorizationRequestId, 200) + && boundedString(record.oauthClientId, 255) + && boundedString(record.oauthClientLabel, 120) + && Array.isArray(record.scopes) + && record.scopes.length === 1 + && record.scopes[0] === 'feed:read' + && Number.isSafeInteger(record.issuedAt) + && Number.isSafeInteger(record.expiresAt) + && Number(record.issuedAt) >= 0 + && Number(record.expiresAt) > Number(record.issuedAt) + && Number(record.expiresAt) <= Number(record.issuedAt) + MAX_TTL_MS; +} + +function canonicalPayload(payload: McpAuthorizationTicketPayload): string { + return JSON.stringify({ + authorizationRequestId: payload.authorizationRequestId, + oauthClientId: payload.oauthClientId, + oauthClientLabel: payload.oauthClientLabel, + scopes: payload.scopes, + issuedAt: payload.issuedAt, + expiresAt: payload.expiresAt, + }); +} + +function signingInput(encodedPayload: string): string { + return `orbit:mcp:authorization-ticket:v1:${encodedPayload}`; +} + +export async function createMcpAuthorizationTicket( + payload: McpAuthorizationTicketPayload, + secret: string, +): Promise { + if (!isPayload(payload)) throw new Error('invalid_mcp_authorization_ticket_payload'); + const encodedPayload = base64UrlEncode(canonicalPayload(payload)); + const signature = await hmacDigest(signingInput(encodedPayload), secret); + return `${PREFIX}.${encodedPayload}.${signature}`; +} + +export async function verifyMcpAuthorizationTicket( + ticket: string, + secret: string, + now: number, +): Promise { + const parts = ticket.split('.'); + if ( + parts.length !== 3 + || parts[0] !== PREFIX + || !BASE64URL_PATTERN.test(parts[1] ?? '') + || !BASE64URL_PATTERN.test(parts[2] ?? '') + ) return null; + + const expected = await hmacDigest(signingInput(parts[1]), secret); + if (!timingSafeEqual(expected, parts[2])) return null; + const decoded = base64UrlDecode(parts[1]); + if (decoded === null) return null; + + try { + const payload = JSON.parse(decoded) as unknown; + if (!isPayload(payload)) return null; + if (payload.issuedAt > now + CLOCK_SKEW_MS || payload.expiresAt <= now) return null; + return payload; + } catch { + return null; + } +} diff --git a/src/server/observability/telemetry.ts b/src/server/observability/telemetry.ts index 778ef50..7078646 100644 --- a/src/server/observability/telemetry.ts +++ b/src/server/observability/telemetry.ts @@ -12,6 +12,8 @@ function routeName(path: string): string { [/^\/v1\/agents\/[^/]+(?:\/manage)?$/u, '/v1/agents/:agent'], [/^\/v1\/approvals\/[^/]+(?:\/(?:approve|reject))?$/u, '/v1/approvals/:review/:action'], [/^\/v1\/sessions\/[^/]+\/revoke$/u, '/v1/sessions/:session/revoke'], + [/^\/v1\/mcp\/authorizations\/[^/]+\/revoke$/u, '/v1/mcp/authorizations/:grant/revoke'], + [/^\/v1\/mcp\/grants\/[^/]+\/resolve$/u, '/v1/mcp/grants/:grant/resolve'], [/^\/v1\/announcements\/[^/]+\/read$/u, '/v1/announcements/:announcement/read'], [/^\/v1\/admin\/announcements\/[^/]+\/(?:publish|withdraw)$/u, '/v1/admin/announcements/:announcement/:action'], [/^\/v1\/admin\/invitations\/[^/]+\/revoke$/u, '/v1/admin/invitations/:invitation/revoke'], @@ -25,8 +27,10 @@ function routeName(path: string): string { return routes.find(([pattern]) => pattern.test(path))?.[1] ?? 'static_asset'; } -function actorType(request: Request): 'agent' | 'account' | 'anonymous' { - if (request.headers.get('authorization')?.startsWith('Bearer ')) return 'agent'; +function actorType(request: Request): 'agent' | 'service' | 'account' | 'anonymous' { + if (request.headers.get('authorization')?.startsWith('Bearer ')) { + return new URL(request.url).pathname.startsWith('/v1/mcp/') ? 'service' : 'agent'; + } if (request.headers.has('cookie')) return 'account'; return 'anonymous'; } diff --git a/src/server/repositories/d1/d1-mcp-authorization-repository.ts b/src/server/repositories/d1/d1-mcp-authorization-repository.ts index 380b730..c14bb2d 100644 --- a/src/server/repositories/d1/d1-mcp-authorization-repository.ts +++ b/src/server/repositories/d1/d1-mcp-authorization-repository.ts @@ -235,6 +235,29 @@ export class D1McpAuthorizationRepository implements McpAuthorizationRepository AND (expires_at IS NULL OR expires_at > ?) AND created_at <= ? AND (last_used_at IS NULL OR last_used_at < ?) + AND EXISTS ( + SELECT 1 + FROM accounts account + WHERE account.id = mcp_authorization_grants.account_id + AND account.status = 'active' + ) + AND ( + EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = mcp_authorization_grants.agent_id + AND membership.account_id = mcp_authorization_grants.account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = mcp_authorization_grants.account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) `).bind( input.usedAt, input.grantId, diff --git a/wrangler.slice1-test.jsonc b/wrangler.slice1-test.jsonc index 34917c5..b0ade14 100644 --- a/wrangler.slice1-test.jsonc +++ b/wrangler.slice1-test.jsonc @@ -18,7 +18,9 @@ "ORBIT_AGENT_CREDENTIAL_PEPPER_V1": "test-agent-pepper-at-least-32-bytes-long", "ORBIT_OAUTH_STATE_PEPPER_V1": "test-oauth-pepper-at-least-32-bytes-long", "ORBIT_CSRF_PEPPER_V1": "test-csrf-pepper-at-least-32-bytes-long", - "ORBIT_CURSOR_PEPPER_V1": "test-cursor-pepper-at-least-32-bytes-long" + "ORBIT_CURSOR_PEPPER_V1": "test-cursor-pepper-at-least-32-bytes-long", + "ORBIT_MCP_DELEGATION_PEPPER_V1": "test-mcp-delegation-pepper-at-least-32-bytes-long", + "ORBIT_MCP_SERVICE_SECRET_V1": "test-mcp-service-secret-at-least-32-bytes-long" }, "d1_databases": [ {