Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/cloud/src/mcp/agent-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }),
),
Expand Down
82 changes: 81 additions & 1 deletion apps/cloud/src/mcp/auth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}),
);
});
10 changes: 8 additions & 2 deletions apps/cloud/src/mcp/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
McpOrganizationAuthLive,
type AuthorizedMcpOrganization,
type McpAuthResult,
type VerifiedApiKey,
type VerifiedToken,
} from "./auth";
import type { MovedToV2 } from "./moved-to-v2";
Expand Down Expand Up @@ -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 +
Expand Down Expand Up @@ -138,6 +143,7 @@ const principalFromToken = (
avatarUrl: null,
roles: [],
...(organization.movedToV2 ? { movedToV2: organization.movedToV2 } : {}),
...(token.apiKey ? { apiKey: token.apiKey } : {}),
});

export const cloudMcpAuthProviderLayer: Layer.Layer<
Expand Down
20 changes: 20 additions & 0 deletions apps/cloud/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
McpJwtVerificationError,
verifyMcpAccessToken,
verifyWorkOSMcpAccessToken,
type VerifiedApiKey,
type VerifiedToken,
} from "./jwt";

Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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),
},
});
});

Expand Down
16 changes: 16 additions & 0 deletions apps/cloud/src/mcp/jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")<{
Expand Down
86 changes: 86 additions & 0 deletions apps/cloud/src/mcp/v2-forward.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash, createHmac } from "node:crypto";

import { describe, expect, it, vi } from "@effect/vitest";
import { Data, Effect, Schema } from "effect";

Expand Down Expand Up @@ -36,6 +38,30 @@ const outage = (): Promise<never> => 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<Response>) => {
const seen: Request[] = [];
const env: V2ForwardEnv = {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading