Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
declare global {
namespace Cloudflare {
interface Env {
MCP_CLIENT_CHANNEL: DurableObjectNamespace<
import("./mcp/client-channel-durable-object").McpClientChannelDO
>;
/** TOTP enrollment and verification attempts; absence refuses verification. */
ADMIN_MFA_RATE_LIMITER?: {
readonly limit: (options: {
Expand Down
38 changes: 35 additions & 3 deletions apps/cloud/src/mcp/agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub";

import { wrapMcpSseResponse } from "../observability/memory-metrics";
import { WorkerTelemetryLive } from "../observability/telemetry";
import { cloudMcpAuth } from "./auth-provider";
import { authenticateClientChannelToken, cloudMcpAuth } from "./auth-provider";
import { isMcpSessionMetaUnavailable } from "./session-meta";
import { McpSessionDOSqlite } from "./session-durable-object";
import { parseTraceparent } from "./traceparent";
Expand All @@ -44,7 +44,7 @@ const corsPreflightResponse = (): Response =>
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
"access-control-allow-headers":
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version",
"content-type, authorization, mcp-session-id, accept, mcp-protocol-version, x-executor-client-token",
"access-control-expose-headers": "mcp-session-id, WWW-Authenticate",
},
});
Expand Down Expand Up @@ -171,6 +171,8 @@ const resourceFromPath = (request: Request): McpResource => {
const propsForPrincipal = (
request: Request,
principal: Extract<AuthOutcome, { readonly _tag: "Authenticated" }>["principal"],
clientIdentity: string | undefined,
clientManifestGeneration: number | undefined,
resource: McpResource,
): Effect.Effect<McpSessionProps> =>
Effect.gen(function* () {
Expand All @@ -187,6 +189,8 @@ const propsForPrincipal = (
...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}),
...sessionOrgRoleMetadata(principal),
userId: principal.accountId,
...(clientIdentity === undefined ? {} : { clientIdentity }),
...(clientManifestGeneration === undefined ? {} : { clientManifestGeneration }),
elicitationMode: readElicitationMode(request),
artifactsEnabled: readArtifactsEnabled(request),
searchToolsEnabled: readSearchToolsEnabled(request),
Expand Down Expand Up @@ -246,6 +250,23 @@ export const makeCloudMcpAgentHandler = () => {
return renderAuthError(auth, request, outcome);
}

// A channel token is only a session binding layered on top of the normal
// cloud bearer auth above. Its opaque value selects a DO; identity is read
// from durable server-side state and must match the bearer principal.
const channelToken = request.headers.get("x-executor-client-token");
let clientIdentity = outcome.clientIdentity;
let clientManifestGeneration = outcome.clientManifestGeneration;
if (channelToken !== null) {
const channelIdentity = await authenticateClientChannelToken(
env.MCP_CLIENT_CHANNEL,
channelToken,
outcome.principal,
);
if (!channelIdentity) return jsonRpcResponse(401, -32001, "Invalid client channel token");
clientIdentity = channelIdentity.binding;
clientManifestGeneration = channelIdentity.generation;
}

if (!sessionId && request.method === "DELETE") {
// Matches the old envelope's contract (@modelcontextprotocol/sdk's
// `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
Expand All @@ -266,6 +287,8 @@ export const makeCloudMcpAgentHandler = () => {
{
accountId: outcome.principal.accountId,
organizationId: outcome.principal.organizationId,
...(clientIdentity === undefined ? {} : { clientIdentity }),
...(clientManifestGeneration === undefined ? {} : { clientManifestGeneration }),
},
resource,
);
Expand Down Expand Up @@ -297,7 +320,16 @@ export const makeCloudMcpAgentHandler = () => {
}
}

const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource));
const props = await runTraced(
request,
propsForPrincipal(
request,
outcome.principal,
clientIdentity,
clientManifestGeneration,
resource,
),
);
(ctx as ExecutionContext & { props?: McpSessionProps }).props = props;
const forwarded = withOrgWriteAccess(
withVerifiedIdentityHeaders(
Expand Down
61 changes: 61 additions & 0 deletions apps/cloud/src/mcp/auth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ import { McpAuthProvider, orgWriteAccessForPrincipal } from "@executor-js/host-m

import { WorkOSError } from "../auth/errors";
import { cloudMcpAuthProviderLayer } from "./auth-provider";
import { authenticateClientChannelToken } from "./auth-provider";
import {
MCP_ORGANIZATION_HEADER,
McpAuth,
McpOrganizationAuth,
mcpAuthorized,
mcpUnauthorized,
} from "./auth";
import { authenticateClientToken, createClientChannelState } from "./client-channel";

const ACCOUNT_ID = "user_test";
const ORG_ID = "org_test";
Expand Down Expand Up @@ -96,6 +98,65 @@ const authenticateWith = (
);

describe("cloud MCP org-authorization classification", () => {
it("resolves channel credentials to durable identity and generation, rejecting invalid tokens", () => {
const state = createClientChannelState();
const binding = state.register(
{ accountId: ACCOUNT_ID, organizationId: ORG_ID },
{
tools: [{ name: "alpha" }],
resources: [],
prompts: [],
},
);
expect(typeof binding).toBe("string");
if (typeof binding !== "string") return;
expect(authenticateClientToken(state, binding)).toEqual({
principal: { accountId: ACCOUNT_ID, organizationId: ORG_ID },
generation: 0,
binding,
});
expect(authenticateClientToken(state, "forged-token")).toBeNull();
state.disconnect(binding);
expect(authenticateClientToken(state, binding)).toBeNull();
});

it("authenticates supplied channel tokens against verified tenant identity", async () => {
const state = createClientChannelState();
const binding = state.register(
{ accountId: ACCOUNT_ID, organizationId: ORG_ID },
{
tools: [{ name: "alpha" }],
resources: [],
prompts: [],
},
);
expect(typeof binding).toBe("string");
if (typeof binding !== "string") return;
const namespace = {
idFromName: (name: string) => name,
get: () => ({
authenticate: (token: string) => Promise.resolve(authenticateClientToken(state, token)),
}),
};
await expect(
authenticateClientChannelToken(namespace, binding, {
accountId: ACCOUNT_ID,
organizationId: ORG_ID,
}),
).resolves.toMatchObject({ binding, generation: 0 });
await expect(
authenticateClientChannelToken(namespace, "invalid-token", {
accountId: ACCOUNT_ID,
organizationId: ORG_ID,
}),
).resolves.toBeNull();
await expect(
authenticateClientChannelToken(namespace, binding, {
accountId: "other",
organizationId: ORG_ID,
}),
).resolves.toBeNull();
});
it.effect("WorkOS 5xx -> Unavailable (retryable 503, session preserved)", () =>
Effect.gen(function* () {
const outcome = yield* authenticateWith(stubOrgAuthFailing(new WorkOSError({ status: 503 })));
Expand Down
20 changes: 20 additions & 0 deletions apps/cloud/src/mcp/auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ import {
authorizationServerMetadataResponse,
protectedResourceMetadataResponse,
} from "./oauth-metadata";
import type { McpClientChannelDO } from "./client-channel-durable-object";
import { clientChannelBelongsToPrincipal } from "./client-channel";
import { mcpClientChannelIdFromName } from "@executor-js/cloudflare/mcp/execution-owner-directory";

export interface McpClientChannelNamespace<Id> {
readonly idFromName: (name: string) => Id;
readonly get: (id: Id) => Pick<McpClientChannelDO, "authenticate">;
}

export const authenticateClientChannelToken = async <Id>(
namespace: McpClientChannelNamespace<Id>,
token: string,
principal: Pick<Principal, "accountId" | "organizationId">,
) => {
if (!/^[0-9a-f-]{36}$/i.test(token)) return null;
const channel = namespace.get(mcpClientChannelIdFromName(namespace, token));
const identity = await channel.authenticate(token);
if (!clientChannelBelongsToPrincipal(identity, principal)) return null;
return identity;
};

const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";
const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`;
Expand Down
Loading
Loading