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
109 changes: 109 additions & 0 deletions e2e/selfhost/mcp-oauth-cimd-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// The Add connection flow must honor the authorization server's advertised
// client-registration mechanism. CIMD is preferred over DCR, so a server that
// advertises both receives Executor's hosted metadata-document URL as the
// client_id and never receives a dynamic-registration request.
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
import { IntegrationSlug } from "@executor-js/sdk/shared";
import { OAuthTestServer } from "@executor-js/sdk/testing";

import { scenario } from "../src/scenario";
import { Api, Browser, Target } from "../src/services";
import { visit } from "../src/surfaces/browser";

const api = composePluginApi([mcpHttpPlugin()] as const);

scenario(
"MCP OAuth · advertised CIMD starts authorization without dynamic registration",
{ timeout: 180_000 },
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client: makeApiClient } = yield* Api;
const oauth = yield* OAuthTestServer;
const server = yield* serveMcpServerWithOAuth(
() => makeGreetingMcpServer({ name: "cimd-connect-mcp" }),
{ path: "/mcp" },
);
const identity = yield* target.newIdentity();
const client = yield* makeApiClient(api, identity);
const displayName = `CIMD MCP ${randomBytes(3).toString("hex")}`;
const slug = IntegrationSlug.make(deriveMcpNamespace({ name: displayName }));
const clientsBefore = yield* client.oauth.listClients();
const clientSlugsBefore = new Set(clientsBefore.map((candidate) => candidate.slug));
let createdClientId: string | undefined;

yield* Effect.gen(function* () {
yield* browser.session(identity, async ({ page, step }) => {
await step("Add an OAuth-protected MCP integration", async () => {
const addUrl = new URL("/integrations/add/mcp", target.baseUrl);
addUrl.searchParams.set("url", server.endpoint);
await visit(page, addUrl.toString());
await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });
await page.getByPlaceholder("e.g. Linear").fill(displayName);
await page.getByRole("button", { name: "Add integration" }).click();
await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
await page.getByText("Connections").first().waitFor();
});

await step("Connect with the advertised CIMD client", async () => {
await page.getByRole("button", { name: "Add connection" }).first().click();
await page.getByRole("heading", { name: /Add connection/ }).waitFor();

const popupPromise = page.waitForEvent("popup", { timeout: 30_000 });
await page.getByRole("button", { name: "Connect", exact: true }).click();
const popup = await popupPromise;
await popup.waitForURL((url) => url.pathname === "/login", { timeout: 30_000 });

const authorize = (await Effect.runPromise(oauth.requests)).find(
(request) => request.method === "GET" && request.path === "/authorize",
);
expect(
authorize,
"the popup reached the discovered authorization endpoint",
).toBeDefined();
const clientId = authorize?.query["client_id"];
createdClientId = clientId;
expect(
clientId,
"authorization uses Executor's metadata document as client_id",
).toMatch(/^https?:\/\/[^/]+\/api\/oauth\/client-id-metadata\/.+\.json$/);
await popup.close();
});
});

const requests = yield* oauth.requests;
expect(
requests.filter((request) => request.method === "POST" && request.path === "/register"),
"CIMD wins when the server also advertises DCR",
).toEqual([]);
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
if (createdClientId) {
const clientsAfter = yield* client.oauth.listClients();
const created = clientsAfter.find(
(candidate) =>
candidate.clientId === createdClientId && !clientSlugsBefore.has(candidate.slug),
);
if (created) {
yield* client.oauth.removeClient({
params: { slug: created.slug },
payload: { owner: created.owner },
});
}
}
yield* client.mcp.removeServer({ params: { slug } });
}).pipe(Effect.ignore),
),
);
}),
).pipe(Effect.provide(OAuthTestServer.layer({ clientIdMetadataDocumentSupported: true }))),
);
5 changes: 5 additions & 0 deletions packages/core/sdk/src/testing/oauth-test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface OAuthTestServerOptions {
/** Act as a Resource Authorization Server for the same profile: advertise it
* in RFC 8414 metadata and redeem ID-JAGs presented as RFC 7523 assertions. */
readonly enterpriseResourceServer?: EnterpriseResourceServerOptions;
/** Advertise OAuth Client ID Metadata Document support alongside DCR. */
readonly clientIdMetadataDocumentSupported?: boolean;
}

export interface EnterpriseIdpOptions {
Expand Down Expand Up @@ -617,6 +619,9 @@ export const serveOAuthTestServer = (
authorization_endpoint: `${currentIssuerUrl}/authorize`,
token_endpoint: `${currentIssuerUrl}/token`,
registration_endpoint: `${currentIssuerUrl}/register`,
...(options.clientIdMetadataDocumentSupported === true
? { client_id_metadata_document_supported: true }
: {}),
response_types_supported: ["code"],
grant_types_supported: [
"authorization_code",
Expand Down
99 changes: 87 additions & 12 deletions packages/react/src/components/add-account-modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
OAuthClientSlug,
ProviderItemId,
ProviderKey,
type OAuthProbeResult,
type Owner,
} from "@executor-js/sdk/shared";

Expand All @@ -18,8 +19,8 @@ import {
DEFAULT_CONNECTION_OWNER,
mergeCustomMethods,
oauthIdentityLabelFromHealth,
runAutomaticOAuthConnect,
runCimdConnect,
runDcrConnect,
typedIdentityLabel,
uniqueConnectionName,
} from "./add-account-modal";
Expand All @@ -33,15 +34,7 @@ const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): Aut
placements: [{ carrier: "header", name: "Authorization", prefix: "" }],
});

type ProbeResult = {
readonly issuer?: string | null;
readonly authorizationUrl: string;
readonly tokenUrl: string;
readonly resource?: string | null;
readonly scopesSupported?: readonly string[];
readonly registrationEndpoint?: string | null;
readonly tokenEndpointAuthMethodsSupported?: readonly string[];
};
type ProbeResult = OAuthProbeResult;

type RegisterArgs = {
readonly owner: Owner;
Expand Down Expand Up @@ -88,6 +81,30 @@ const popupSpy = (reservation: OAuthPopupReservation = RESERVED) => {
};
};

type AutomaticOAuthDeps = Parameters<typeof runAutomaticOAuthConnect>[0];
type AutomaticOAuthInput = Parameters<typeof runAutomaticOAuthConnect>[1];

/** DCR-focused tests use defaults for the CIMD branch they intentionally do
* not exercise. Discovery-level tests call the orchestrator directly. */
const runDcrConnect = (
deps: Omit<AutomaticOAuthDeps, "createCimdClient">,
input: Omit<AutomaticOAuthInput, "cimd">,
) =>
runAutomaticOAuthConnect(
{
...deps,
createCimdClient: (): Promise<OAuthClientSlug | null> => Promise.resolve(null),
},
{
...input,
cimd: {
integrationName: "Test MCP",
clientIdMetadataDocumentUrl: "https://executor.example/api/oauth/client-id-metadata.json",
existingClients: [],
},
},
);

type CimdCreateArgs = {
readonly owner: Owner;
readonly slug: OAuthClientSlug;
Expand Down Expand Up @@ -424,6 +441,64 @@ describe("runCimdConnect", () => {
});
});

describe("runAutomaticOAuthConnect", () => {
it("prefers advertised CIMD over DCR and keeps the original popup reservation", async () => {
const popup = popupSpy();
const calls: string[] = [];
let createArgs: CimdCreateArgs | null = null;
let startArgs: StartArgs | null = null;

const outcome = await runAutomaticOAuthConnect(
{
...popup,
probe: (): Promise<ProbeResult> => {
calls.push("probe");
return Promise.resolve({
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
resource: "https://mcp.example.com/mcp",
registrationEndpoint: "https://auth.example.com/register",
clientIdMetadataDocumentSupported: true,
});
},
createCimdClient: (args: CimdCreateArgs): Promise<OAuthClientSlug> => {
calls.push("create-cimd");
createArgs = args;
return Promise.resolve(args.slug);
},
register: (): Promise<OAuthClientSlug> => {
calls.push("register-dcr");
return Promise.resolve(OAuthClientSlug.make("unexpected-dcr-client"));
},
start: (args: StartArgs): void => {
calls.push("start");
startArgs = args;
},
},
{
discoveryUrl: "https://mcp.example.com/mcp",
resourceFallback: "https://mcp.example.com/mcp",
owner: "user",
integration: TEST_INTEGRATION,
cimd: {
integrationName: "Test MCP",
clientIdMetadataDocumentUrl: "https://executor.example/api/oauth/client-id-metadata.json",
existingClients: [],
},
},
);

expect(outcome).toEqual({ kind: "started", flow: "cimd" });
expect(calls).toEqual(["probe", "create-cimd", "start"]);
expect(createArgs).toMatchObject({
clientId: "https://executor.example/api/oauth/client-id-metadata.json",
clientSecret: "",
resource: "https://mcp.example.com/mcp",
});
expect(startArgs!.reservation).toBe(RESERVED);
});
});

describe("runDcrConnect popup reservation", () => {
const probeOk = (): Promise<ProbeResult> =>
Promise.resolve({
Expand Down Expand Up @@ -461,7 +536,7 @@ describe("runDcrConnect popup reservation", () => {
dcrInput,
);

expect(outcome).toEqual({ kind: "started" });
expect(outcome).toEqual({ kind: "started", flow: "dcr" });
expect(popup.calls).toEqual(["reserve", "probe", "register", "start"]);
});

Expand All @@ -479,7 +554,7 @@ describe("runDcrConnect popup reservation", () => {
dcrInput,
);

expect(outcome).toEqual({ kind: "started" });
expect(outcome).toEqual({ kind: "started", flow: "dcr" });
expect(startArgs!.reservation).toBe(RESERVED);
});

Expand Down
Loading
Loading