176 lines · 8.1 KB · TypeScript
RawDownload
// Cloud: the DCR root-domain collision, end to end over the product API.//// The bug this guards (issue #1120): two DIFFERENT integrations can share one// registrable root domain but differ by host, e.g. a "Cloudflare" MCP// integration whose OAuth lives on mcp.cloudflare.com vs the plain "Cloudflare"// REST integration on api.cloudflare.com. When the MCP integration is connected// it Dynamic-Client-Registers an oauth_client automatically. That auto-minted// client must NEVER leak into the REST integration's Add-connection app picker// (it is plumbing, not an app the user picked), and connecting the MCP// integration a second time must REUSE the same client rather than mint a// "name 2" duplicate.//// Everything here is over the wire: the typed product client drives the real// cloud API, and a real authorization server (with an RFC 7591 registration// endpoint) runs inside the scenario on 127.0.0.1. To reproduce the collision// the scenario stores mcp.cloudflare.com / api.cloudflare.com as the clients'// endpoint URLs (the classifier keys off those strings, never the network)// while pointing the actual registration round-trip at the local server.//// This e2e proves the SERVER-SIDE half over the wire: (b) DCR mints a client// against the MCP host, (d) reconnecting reuses it with no duplicate row, and// the REAL `listClients` projection marks that client as// `dynamic_client_registration` while preserving its mcp.cloudflare.com// endpoints. Those two facts (origin kind + persisted host) are the entire// input the picker classifier keys off. The picker's own verdict for the REST// integration (that the DCR client is offered in no tier) is asserted against// the exact production classifier in the React package's integration test// (packages/react/src/plugins/dcr-root-domain-isolation.test.ts); it lives// there because that pure function ships in @executor-js/react, and the e2e// package deliberately does not depend on the React layer. See the report.import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest";import { Effect } from "effect";import { composePluginApi } from "@executor-js/api/server";import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";import { serveOAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario";import { Api, Target } from "../src/services"; const api = composePluginApi([openApiHttpPlugin()] as const); const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; // A same-provider, different-host pair: same registrable root (cloudflare.com),// distinct hosts. These are the endpoint URLs the clients are PERSISTED with,// which is all the classifier looks at.const MCP_ISSUER = "https://mcp.cloudflare.com";const MCP_AUTHORIZE = "https://mcp.cloudflare.com/authorize";const MCP_TOKEN = "https://mcp.cloudflare.com/token";const REST_AUTHORIZE = "https://api.cloudflare.com/client/v4/oauth/authorize";const REST_TOKEN = "https://api.cloudflare.com/client/v4/oauth/token"; scenario(  "OAuth DCR · an MCP integration's auto-registered client never leaks into a same-root REST integration's app picker, and reconnecting reuses it",  {},  Effect.scoped(    Effect.gen(function* () {      const target = yield* Target;      const { client: makeClient } = yield* Api;      // A real authorization server with an RFC 7591 registration endpoint. Its      // /register mints a client_id; the mcp.cloudflare.com URLs above are what      // we persist so the classifier sees the real-world collision shape.      const oauth = yield* serveOAuthTestServer();      const identity = yield* target.newIdentity();      const client = yield* makeClient(api, identity);       // Two integrations on the SAME provider root, different hosts: an MCP-style      // one (mcp.cloudflare.com) and a plain REST one (api.cloudflare.com).      const mcpIntegration = IntegrationSlug.make(unique("cloudflare_mcp"));      const restIntegration = IntegrationSlug.make(unique("cloudflare_api"));       // The REST integration declares its OAuth endpoints up front (api.cloudflare.com).      yield* client.openapi.addSpec({        payload: {          spec: {            kind: "blob",            value: JSON.stringify({              openapi: "3.0.3",              info: { title: "Cloudflare REST API", version: "1.0.0" },              paths: {                "/zones": {                  get: {                    operationId: "listZones",                    tags: ["default"],                    responses: { "200": { description: "zones" } },                  },                },              },            }),          },          slug: restIntegration,          baseUrl: "https://api.cloudflare.com/client/v4",          authenticationTemplate: [            {              slug: "oauth",              kind: "oauth2",              authorizationUrl: REST_AUTHORIZE,              tokenUrl: REST_TOKEN,              scopes: ["read"],            },          ],        },      });       // (b) Connect the MCP-style integration via Dynamic Client Registration.      // The registration round-trip hits the real local server; the persisted      // client carries the mcp.cloudflare.com endpoints and its issuer.      const registerMcp = () =>        client.oauth.registerDynamic({          payload: {            owner: "org",            // Passed but overridden by the server's deterministic dcr-<host> slug            // when an issuer is present; kept as a fallback for the no-issuer path.            slug: OAuthClientSlug.make(unique("cloudflare-mcp")),            issuer: MCP_ISSUER,            registrationEndpoint: oauth.registrationEndpoint,            authorizationUrl: MCP_AUTHORIZE,            tokenUrl: MCP_TOKEN,            scopes: ["read"],            clientName: "Cloudflare MCP",            originIntegration: mcpIntegration,          },        });       const first = yield* registerMcp();      expect(        String(first.client),        "the auto-registered client gets a deterministic issuer-host slug",      ).toBe("dcr-mcp-cloudflare-com");       // (d) Reconnect the MCP integration: the SAME authorization server (issuer)      // must reuse the existing client, not mint a duplicate "…-2".      const second = yield* registerMcp();      expect(        String(second.client),        "reconnecting the MCP integration reuses the same DCR client (no duplicate row)",      ).toBe(String(first.client));       // The REAL API projection of every client the owner can see. This is the      // exact shape the picker classifier consumes.      const clients = yield* client.oauth.listClients();      const dcrRows = clients.filter(        (entry) => entry.origin.kind === "dynamic_client_registration",      );      expect(        dcrRows.map((entry) => String(entry.slug)),        "exactly one DCR client exists for the shared authorization server (no duplicate)",      ).toEqual(["dcr-mcp-cloudflare-com"]);       const dcrRow = dcrRows[0]!;      // The picker excludes a client iff its origin is DCR — assert the API      // projects that origin so the classifier will hide it.      expect(        dcrRow.origin.kind,        "the auto-registered client is projected as a DCR client (the flag the picker hides on)",      ).toBe("dynamic_client_registration");      // The persisted endpoints are the MCP host, NOT the local registration      // server: this is the same-root/different-host collision the classifier      // must not silently promote into the REST integration's picker.      expect(        dcrRow.tokenUrl,        "the DCR client keeps the MCP host it was registered for, not the registration endpoint",      ).toBe(MCP_TOKEN);      // Sanity: the REST integration itself has NO manual app, so its picker      // would fall back to near/other tiers if the DCR client were ever      // classified as a real app — the React test proves it never is.      expect(        clients.some((entry) => entry.origin.kind === "manual"),        "no manual OAuth app was registered in this scenario",      ).toBe(false);    }),  ),);