diff --git a/apps/cloud/src/mcp/agent-handler.test.ts b/apps/cloud/src/mcp/agent-handler.test.ts index 826095a73b..5dbefe995d 100644 --- a/apps/cloud/src/mcp/agent-handler.test.ts +++ b/apps/cloud/src/mcp/agent-handler.test.ts @@ -99,6 +99,22 @@ describe("cloud MCP handler v2 forwarding", () => { expect(mocks.serveFetch).toHaveBeenCalledTimes(1); }); + it("hands an API-key caller's key to the v2 forward", async () => { + const apiKey = { id: "api_key_EXAMPLE", organizationId: "org_KEY_OWNER", hash: "h".repeat(43) }; + mocks.principal = { ...base, movedToV2: { v2OrganizationId: "v2-org" }, apiKey }; + mocks.forwardToV2.mockClear(); + mocks.forwardToV2.mockResolvedValue(new Response("from v2", { status: 200 })); + + await makeCloudMcpAgentHandler()(post(), env, ctx); + + expect(mocks.forwardToV2).toHaveBeenCalledTimes(1); + expect(mocks.forwardToV2.mock.calls[0]?.[1]).toMatchObject({ + accountId: "user_EXAMPLE", + organizationId: "org_EXAMPLE", + apiKey, + }); + }); + it("answers OPTIONS before authenticating, even for a moved org", async () => { mocks.principal = { ...base, movedToV2: { v2OrganizationId: "v2-org" } }; mocks.forwardToV2.mockClear(); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index c4072eaedb..dd15f5821f 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -261,12 +261,13 @@ export const makeCloudMcpAgentHandler = () => { // An organization that has moved to v2 is served by v2 instead of the // session Durable Objects. This runs before the session owner check: v2 // issues its own session ids, which no v1 DO knows. - const { movedToV2 } = outcome.principal as CloudPrincipal; + const cloudPrincipal = outcome.principal as CloudPrincipal; + const { movedToV2 } = cloudPrincipal; if (movedToV2) { return runTraced( request, Effect.promise(() => - forwardToV2(request, outcome.principal, movedToV2, env, { resolveToolkitRowId }), + forwardToV2(request, cloudPrincipal, movedToV2, env, { resolveToolkitRowId }), ).pipe( Effect.withSpan("mcp.forward.v2", { attributes: { [FORWARD_SPAN_ATTRIBUTE]: true } }), ), diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 2e1ffbe6b2..7ee9c7466e 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -19,16 +19,24 @@ // a lookup that succeeds with an org id is `Authenticated`. // --------------------------------------------------------------------------- +import { createHash } from "node:crypto"; + import { describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer, Predicate } from "effect"; -import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-mcp"; +import { + McpAuthProvider, + orgWriteAccessForPrincipal, + type AuthOutcome, +} from "@executor-js/host-mcp"; +import { ApiKeyService } from "../auth/api-keys"; import { WorkOSError } from "../auth/errors"; import { cloudMcpAuthProviderLayer, type CloudPrincipal } from "./auth-provider"; import { MCP_ORGANIZATION_HEADER, McpAuth, + McpAuthLive, McpOrganizationAuth, mcpAuthorized, mcpUnauthorized, @@ -280,3 +288,75 @@ describe("cloud MCP org-authorization classification", () => { }), ); }); + +describe("cloud MCP API-key callers", () => { + const API_KEY = "sk_test_synthetic_key_value"; + // The key belongs to another organization than the one the URL selects. + const KEY_ORG_ID = "org_key_owner"; + + // The real `McpAuthLive` bearer dispatch over a stubbed WorkOS key lookup. + const apiKeyAuth = McpAuthLive.pipe( + Layer.provide( + Layer.succeed(ApiKeyService)({ + validate: (value) => + Effect.succeed( + value === API_KEY + ? { + scope: "user" as const, + accountId: ACCOUNT_ID, + organizationId: KEY_ORG_ID, + keyId: "api_key_test", + } + : null, + ), + listUserKeys: () => Effect.die("MCP auth test does not list API keys"), + createUserKey: () => Effect.die("MCP auth test does not create API keys"), + revokeUserKey: () => Effect.die("MCP auth test does not revoke API keys"), + listOrgKeys: () => Effect.die("MCP auth test does not list org API keys"), + createOrgKey: () => Effect.die("MCP auth test does not create org API keys"), + revokeOrgKey: () => Effect.die("MCP auth test does not revoke org API keys"), + }), + ), + ); + + const apiKeyRequest = () => + new Request("https://executor.sh/mcp", { + method: "POST", + headers: { + authorization: `Bearer ${API_KEY} `, + [MCP_ORGANIZATION_HEADER]: ORG_ID, + "content-type": "application/json", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }), + }); + + const apiKeyOf = (outcome: AuthOutcome) => + Predicate.isTagged(outcome, "Authenticated") + ? (outcome.principal as CloudPrincipal).apiKey + : "not authenticated"; + + it.effect("the principal carries the key id, the key's organization and its hash", () => + Effect.gen(function* () { + const outcome = yield* authenticateWith(stubOrgAuthActive, apiKeyAuth, apiKeyRequest); + const hash = createHash("sha256").update(API_KEY).digest("base64url"); + expect(hash).toHaveLength(43); + expect(apiKeyOf(outcome)).toEqual({ + id: "api_key_test", + organizationId: KEY_ORG_ID, + hash, + }); + const principal = Predicate.isTagged(outcome, "Authenticated") ? outcome.principal : null; + expect(principal?.organizationId, "the URL selector still picks the organization").toBe( + ORG_ID, + ); + }), + ); + + it.effect("a JWT caller's principal carries no API key", () => + Effect.gen(function* () { + const outcome = yield* authenticateWith(stubOrgAuthActive); + expect(Predicate.isTagged(outcome, "Authenticated")).toBe(true); + expect(apiKeyOf(outcome)).toBeUndefined(); + }), + ); +}); diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index a4955bfc76..87f5934fea 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -61,6 +61,7 @@ import { McpOrganizationAuthLive, type AuthorizedMcpOrganization, type McpAuthResult, + type VerifiedApiKey, type VerifiedToken, } from "./auth"; import type { MovedToV2 } from "./moved-to-v2"; @@ -107,9 +108,13 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = /** * The principal cloud's auth hands the MCP handler: the shared shape plus the - * org's moved-to-v2 mark, which only the cloud handler reads. + * org's moved-to-v2 mark and, for an API-key caller, the key it presented, + * which only the cloud handler reads (to forward them to v2). */ -export type CloudPrincipal = Principal & { readonly movedToV2?: MovedToV2 }; +export type CloudPrincipal = Principal & { + readonly movedToV2?: MovedToV2; + readonly apiKey?: VerifiedApiKey; +}; /** * Enrich a cloud {@link VerifiedToken} (which carries only accountId + @@ -138,6 +143,7 @@ const principalFromToken = ( avatarUrl: null, roles: [], ...(organization.movedToV2 ? { movedToV2: organization.movedToV2 } : {}), + ...(token.apiKey ? { apiKey: token.apiKey } : {}), }); export const cloudMcpAuthProviderLayer: Layer.Layer< diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 8f2edd819d..fc8bb7f128 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -29,6 +29,7 @@ export { McpJwtVerificationError, verifyMcpAccessToken, verifyWorkOSMcpAccessToken, + type VerifiedApiKey, type VerifiedToken, } from "./jwt"; @@ -270,6 +271,20 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; +/** + * Unpadded base64url (RFC 4648 ยง5) of the SHA-256 digest of the API key's UTF-8 + * bytes: the same value as Better Auth's `defaultKeyHasher`, which Executor v2 + * stores for its own API keys. + */ +export const hashApiKey = (key: string) => + Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(key))).pipe( + Effect.map((digest) => { + let binary = ""; + for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + }), + ); + export const McpAuthLive = Layer.effect( McpAuth, Effect.gen(function* () { @@ -313,6 +328,11 @@ export const McpAuthLive = Layer.effect( return mcpAuthorized({ accountId: principal.accountId, organizationId: principal.organizationId, + apiKey: { + id: principal.keyId, + organizationId: principal.organizationId, + hash: yield* hashApiKey(token), + }, }); }); diff --git a/apps/cloud/src/mcp/jwt.ts b/apps/cloud/src/mcp/jwt.ts index 93ab7449de..52c067dfd4 100644 --- a/apps/cloud/src/mcp/jwt.ts +++ b/apps/cloud/src/mcp/jwt.ts @@ -28,11 +28,27 @@ const identityFromClaims = (payload: unknown): VerifiedToken | null => }), }); +/** + * The WorkOS API key a caller authenticated with. Forwarded to Executor v2 in + * the signed forwarding token so v2 can store the key as its own. Never log it + * or put it on a span. + */ +export type VerifiedApiKey = { + /** The WorkOS API key id. */ + readonly id: string; + /** The key's own WorkOS organization id. */ + readonly organizationId: string; + /** Unpadded base64url of the SHA-256 digest of the presented key. */ + readonly hash: string; +}; + export type VerifiedToken = { /** The WorkOS account ID (user ID). */ accountId: string; /** The WorkOS organization ID, if the session has org context. */ organizationId: string | null; + /** Set only when the caller authenticated with a WorkOS API key. */ + apiKey?: VerifiedApiKey; }; export class McpJwtVerificationError extends Data.TaggedError("McpJwtVerificationError")<{ diff --git a/apps/cloud/src/mcp/v2-forward.test.ts b/apps/cloud/src/mcp/v2-forward.test.ts index f3b4bcc414..50d75b9ad0 100644 --- a/apps/cloud/src/mcp/v2-forward.test.ts +++ b/apps/cloud/src/mcp/v2-forward.test.ts @@ -1,3 +1,5 @@ +import { createHash, createHmac } from "node:crypto"; + import { describe, expect, it, vi } from "@effect/vitest"; import { Data, Effect, Schema } from "effect"; @@ -36,6 +38,30 @@ const outage = (): Promise => Effect.runPromise(Effect.fail(new Simulated const principal = { accountId: "user_EXAMPLE", organizationId: "org_EXAMPLE" } as const; const moved = { v2OrganizationId: V2_ORG } as const; +const API_KEY = "sk_test_synthetic_key_value"; +// Computed independently of the code under test: unpadded base64url SHA-256. +const API_KEY_HASH = createHash("sha256").update(API_KEY).digest("base64url"); + +// An API-key caller whose key belongs to a different organization than the one +// the URL selects: the forwarded `organization` stays the selected one. +const apiKeyPrincipal = { + ...principal, + apiKey: { id: "api_key_EXAMPLE", organizationId: "org_KEY_OWNER", hash: API_KEY_HASH }, +} as const; + +/** True when `token`'s signature is HMAC-SHA256 over "v1fwd." + payload under `secret`. */ +const signatureVerifies = (token: string, secret: string): boolean => { + const [prefix, payload, signature] = token.split("."); + return ( + createHmac("sha256", secret).update(`${prefix}.${payload}`).digest("base64url") === signature + ); +}; + +const rawPayload = (token: string): string => { + const payload = token.split(".")[1] ?? ""; + return Buffer.from(payload, "base64url").toString("utf8"); +}; + const makeEnv = (respond: (request: Request) => Response | Promise) => { const seen: Request[] = []; const env: V2ForwardEnv = { @@ -85,6 +111,28 @@ describe("forward token", () => { connection: "v1-abc", }); }); + + it("puts the API key last, after the toolkit, and still signs the payload", async () => { + const token = await mintForwardToken(SECRET, { + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: 2000000000, + toolkit: { id: "tk_row_example", connection: "v1-abc" }, + apiKey: { id: "api_key_EXAMPLE", organization: "org_KEY_OWNER", hash: API_KEY_HASH }, + }); + expect(rawPayload(token)).toBe( + JSON.stringify({ + v: 1, + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: 2000000000, + toolkit: "tk_row_example", + connection: "v1-abc", + apiKey: { id: "api_key_EXAMPLE", organization: "org_KEY_OWNER", hash: API_KEY_HASH }, + }), + ); + expect(signatureVerifies(token, SECRET)).toBe(true); + }); }); describe("toolkit connection id", () => { @@ -254,6 +302,44 @@ describe("forwardToV2", () => { ); }); + it("names an API-key caller's key, its own organization and its hash", async () => { + const { env, seen } = makeEnv(() => new Response("ok")); + await forwardToV2( + new Request("https://executor.sh/mcp", { method: "POST", body: "{}" }), + apiKeyPrincipal, + moved, + env, + makeDeps(), + ); + + const token = seen[0]?.headers.get("authorization")?.replace("Bearer ", "") ?? ""; + expect(API_KEY_HASH).toHaveLength(43); + expect(decodePayload(token)).toEqual({ + v: 1, + account: "user_EXAMPLE", + organization: "org_EXAMPLE", + exp: NOW + FORWARD_TOKEN_TTL_SECONDS, + apiKey: { id: "api_key_EXAMPLE", organization: "org_KEY_OWNER", hash: API_KEY_HASH }, + }); + expect(signatureVerifies(token, SECRET)).toBe(true); + expect(rawPayload(token)).not.toContain(API_KEY); + }); + + it("sends no API key for a caller without one", async () => { + const { env, seen } = makeEnv(() => new Response("ok")); + await forwardToV2( + new Request("https://executor.sh/mcp", { method: "POST", body: "{}" }), + principal, + moved, + env, + makeDeps(), + ); + + const token = seen[0]?.headers.get("authorization")?.replace("Bearer ", "") ?? ""; + expect(decodePayload(token)).not.toHaveProperty("apiKey"); + expect(signatureVerifies(token, SECRET)).toBe(true); + }); + it("sends a GET without a body", async () => { const { env, seen } = makeEnv(() => new Response(null, { status: 200 })); await forwardToV2( diff --git a/apps/cloud/src/mcp/v2-forward.ts b/apps/cloud/src/mcp/v2-forward.ts index 57997d1fe1..c2337fd6fb 100644 --- a/apps/cloud/src/mcp/v2-forward.ts +++ b/apps/cloud/src/mcp/v2-forward.ts @@ -10,10 +10,17 @@ // Authorization: Bearer v1fwd.. // // payload base64url (no padding) of the UTF-8 JSON -// {"v":1,"account","organization","exp"[,"toolkit","connection"]} +// {"v":1,"account","organization","exp"[,"toolkit","connection"] +// [,"apiKey":{"id","organization","hash"}]} // sig base64url (no padding) of HMAC-SHA256 over "v1fwd." + payload, // keyed by the shared secret's UTF-8 bytes // +// `apiKey` is present only for a caller that authenticated with a WorkOS API +// key: its id, the key's own organization (which can differ from the forwarded +// `organization`), and the unpadded base64url SHA-256 of the key, so v2 can +// store the key as its own. It travels only inside this signed token over the +// service binding and is never logged. +// // A fresh token is minted per forwarded request and expires after ten // minutes. Nothing here falls back to v1 data: when the binding or secret is // missing, or v2 cannot be reached, the caller gets a retryable 503. @@ -24,7 +31,7 @@ import { readElicitationMode } from "@executor-js/cloudflare/mcp/do-headers"; import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; import type { MovedToV2 } from "./moved-to-v2"; -import { MCP_ORGANIZATION_HEADER, toolkitSlugFromRequest } from "./auth"; +import { MCP_ORGANIZATION_HEADER, toolkitSlugFromRequest, type VerifiedApiKey } from "./auth"; const TOKEN_PREFIX = "v1fwd"; const TOKEN_VERSION = 1; @@ -53,6 +60,12 @@ export interface ForwardTokenClaims { readonly exp: number; /** The v1 toolkit row id and the v2 connection id, for a toolkit URL. */ readonly toolkit?: { readonly id: string; readonly connection: string }; + /** The WorkOS API key the caller authenticated with, for an API-key caller. */ + readonly apiKey?: { + readonly id: string; + readonly organization: string; + readonly hash: string; + }; } export const mintForwardToken = async ( @@ -69,6 +82,15 @@ export const mintForwardToken = async ( ...(claims.toolkit ? { toolkit: claims.toolkit.id, connection: claims.toolkit.connection } : {}), + ...(claims.apiKey + ? { + apiKey: { + id: claims.apiKey.id, + organization: claims.apiKey.organization, + hash: claims.apiKey.hash, + }, + } + : {}), }), ), ); @@ -186,6 +208,8 @@ export interface V2ForwardPrincipal { readonly accountId: string; readonly organizationId: string; readonly movedToV2?: MovedToV2; + /** Set only when the caller authenticated with a WorkOS API key. */ + readonly apiKey?: VerifiedApiKey; } export interface V2ForwardDeps { @@ -247,6 +271,15 @@ export const forwardToV2 = async ( organization: principal.organizationId, exp: nowSeconds + FORWARD_TOKEN_TTL_SECONDS, ...(toolkit ? { toolkit } : {}), + ...(principal.apiKey + ? { + apiKey: { + id: principal.apiKey.id, + organization: principal.apiKey.organizationId, + hash: principal.apiKey.hash, + }, + } + : {}), }); const hasBody = request.method !== "GET" && request.method !== "HEAD";