diff --git a/.changeset/selfhost-cimd-dcr-fallback.md b/.changeset/selfhost-cimd-dcr-fallback.md new file mode 100644 index 000000000..f1a87b7b7 --- /dev/null +++ b/.changeset/selfhost-cimd-dcr-fallback.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Allow self-hosted deployments whose CIMD document is unreachable by OAuth servers to use DCR for automatic MCP and discovered OpenAPI connections with `EXECUTOR_OAUTH_CIMD_ENABLED=false`. Unsetting the variable restores CIMD for new connections without rewriting integration settings, including legacy OpenAPI templates. diff --git a/apps/docs/hosted/docker.mdx b/apps/docs/hosted/docker.mdx index 8fc30f0e8..a5e544cda 100644 --- a/apps/docs/hosted/docker.mdx +++ b/apps/docs/hosted/docker.mdx @@ -53,22 +53,23 @@ Back it up by snapshotting that volume (or copying `/data`, primarily `data.db`) Everything is optional: a bare run boots a working instance. The defaults below are the container defaults. -| Variable | Default | Purpose | -| ----------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------- | -| `PORT` | `4788` | HTTP port the server listens on. | -| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | -| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | -| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | -| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | -| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | -| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | -| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | -| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | -| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | -| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | -| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | -| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | -| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| Variable | Default | Purpose | +| ----------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `4788` | HTTP port the server listens on. | +| `EXECUTOR_HOST` | `0.0.0.0` | Bind address. The image binds all interfaces. | +| `EXECUTOR_DATA_DIR` | `/data` | Directory holding the database and generated keys. | +| `EXECUTOR_DB_PATH` | `/data.db` | SQLite database file. | +| `EXECUTOR_WEB_BASE_URL` | auto (`http://localhost:4788`) | Public URL browsers use. Required behind a domain or TLS (see below). | +| `EXECUTOR_TRUSTED_ORIGINS` | unset | Comma-separated browser aliases allowed to authenticate without changing the public URL. | +| `BETTER_AUTH_SECRET` | generated, persisted in `/data` | Session secret (32+ chars). Rotating it signs everyone out. | +| `EXECUTOR_SECRET_KEY` | generated, persisted in `/data` | Master key encrypting stored secrets. Set it to manage it yourself. | +| `EXECUTOR_BOOTSTRAP_ADMIN_EMAIL` | unset | Pre-create the admin headlessly (with the password below); skips browser first-run. | +| `EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD` | unset | Password for the bootstrap admin. | +| `EXECUTOR_BOOTSTRAP_ADMIN_NAME` | `Admin` | Display name for the bootstrap admin. | +| `EXECUTOR_ORG_NAME` | `Default` | Display name of the single org every user joins. | +| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. | +| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. | +| `EXECUTOR_OAUTH_CIMD_ENABLED` | `true` | Set `false` when upstream authorization servers cannot reach this instance's OAuth Client ID Metadata Document (CIMD); automatic connects then try Dynamic Client Registration (DCR) when available. Only exact `true` or `false` are accepted; any other value (including empty, uppercase, or whitespace-padded values) prevents startup. | Tracing is configured separately, and off unless you turn it on — see [Tracing](/hosted/tracing). diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 1eb13376a..a4ac27101 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -36,6 +36,10 @@ # default — adversarial generated code should not reach your internal network. # EXECUTOR_ALLOW_LOCAL_NETWORK=false +# OAuth Client ID Metadata Document capability. For values and deployment +# guidance, see ../docs/hosted/docker.mdx#environment-variables. +# EXECUTOR_OAUTH_CIMD_ENABLED=true + # --- Local stdio MCP (trusted deployments only) ------------------------------- # Stdio MCP is disabled unless this is explicitly set to the exact string # "true". Enabling it lets users configure MCP servers whose commands execute diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index ab443f3bf..654443ad6 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -86,6 +86,11 @@ export interface SelfHostConfig { * re-sync, leaving stale-marking and config revision as the only triggers. */ readonly toolsSyncTtlMs: number | null | undefined; + /** + * Resolved `EXECUTOR_OAUTH_CIMD_ENABLED`; see apps/docs/hosted/docker.mdx. + * Passed to `ExecutorConfig.oauthClientIdMetadataDocumentEnabled`. + */ + readonly oauthCimdEnabled: boolean; } export const resolveDataDir = (): string => @@ -197,6 +202,7 @@ export const loadConfig = (): SelfHostConfig => { sso: resolveSso(), mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(), toolsSyncTtlMs: resolveToolsSyncTtlMs(), + oauthCimdEnabled: resolveOauthCimdEnabled(), }; }; @@ -256,6 +262,14 @@ const resolveSso = (): SsoConfig | undefined => { return { providerId, providerName, discoveryUrl, clientId, clientSecret, allowedDomains }; }; +const resolveOauthCimdEnabled = (): boolean => { + const raw = process.env.EXECUTOR_OAUTH_CIMD_ENABLED; + if (raw === undefined || raw === "true") return true; + if (raw === "false") return false; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error(`EXECUTOR_OAUTH_CIMD_ENABLED ${JSON.stringify(raw)} must be "true" or "false"`); +}; + // A malformed value is refused rather than silently ignored: an operator who // sets the knob and typos it should find out at boot, not by watching a // runaway execution use the 5-minute default. diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index 8dab577b9..ad4c97451 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -56,6 +56,7 @@ export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig webBaseUrl: config.webBaseUrl, oauthCallbackPath: "/api/oauth/callback", toolsSyncTtlMs: config.toolsSyncTtlMs, + oauthClientIdMetadataDocumentEnabled: config.oauthCimdEnabled, onIntegrationChange: (event) => selfHostAnalytics.record( event.kind === "added" ? "integration_added" : "integration_removed", diff --git a/apps/host-selfhost/src/executor-config.test.ts b/apps/host-selfhost/src/executor-config.test.ts index 313d097b8..e0e825f3e 100644 --- a/apps/host-selfhost/src/executor-config.test.ts +++ b/apps/host-selfhost/src/executor-config.test.ts @@ -6,9 +6,11 @@ import executorConfig from "../executor.config"; const ENV_NAME = "EXECUTOR_ALLOW_STDIO_MCP"; const SECRET_ENV_NAME = "EXECUTOR_SECRET_KEY"; const TTL_ENV_NAME = "EXECUTOR_TOOLS_SYNC_TTL_MS"; +const CIMD_ENV_NAME = "EXECUTOR_OAUTH_CIMD_ENABLED"; const originalValue = process.env[ENV_NAME]; const originalSecret = process.env[SECRET_ENV_NAME]; const originalTtl = process.env[TTL_ENV_NAME]; +const originalCimd = process.env[CIMD_ENV_NAME]; beforeEach(() => { process.env[SECRET_ENV_NAME] = originalSecret ?? "executor-config-test-secret"; @@ -30,6 +32,11 @@ afterEach(() => { } else { process.env[TTL_ENV_NAME] = originalTtl; } + if (originalCimd === undefined) { + delete process.env[CIMD_ENV_NAME]; + } else { + process.env[CIMD_ENV_NAME] = originalCimd; + } }); const allowStdio = (): boolean => { @@ -112,3 +119,28 @@ test("a negative tools-sync TTL refuses to boot", () => { process.env[TTL_ENV_NAME] = "-1"; expect(() => loadConfig()).toThrow(/must not be negative/); }); + +test("CIMD serving is enabled when the knob is unset", () => { + delete process.env[CIMD_ENV_NAME]; + expect(loadConfig().oauthCimdEnabled).toBe(true); +}); + +test("CIMD serving is disabled by false", () => { + process.env[CIMD_ENV_NAME] = "false"; + expect(loadConfig().oauthCimdEnabled).toBe(false); +}); + +test("CIMD serving is enabled by true", () => { + process.env[CIMD_ENV_NAME] = "true"; + expect(loadConfig().oauthCimdEnabled).toBe(true); +}); + +test.each(["disabled", "TRUE", "FALSE", "", " ", " true", "true ", " false", "false "])( + "a malformed CIMD serving knob (%j) refuses to boot", + (raw) => { + process.env[CIMD_ENV_NAME] = raw; + expect(() => loadConfig()).toThrow( + `EXECUTOR_OAUTH_CIMD_ENABLED ${JSON.stringify(raw)} must be "true" or "false"`, + ); + }, +); diff --git a/apps/host-selfhost/src/oauth-cimd-capability.test.ts b/apps/host-selfhost/src/oauth-cimd-capability.test.ts new file mode 100644 index 000000000..0f332c6be --- /dev/null +++ b/apps/host-selfhost/src/oauth-cimd-capability.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, expect, test } from "@effect/vitest"; +import { Effect } from "effect"; + +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +// Config reads the environment, so set the knob (and allow the loopback test +// AS through the hosted HTTP client) before importing the app graph. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-cimd-")); +process.env.EXECUTOR_OAUTH_CIMD_ENABLED = "false"; +process.env.EXECUTOR_ALLOW_LOCAL_NETWORK = "true"; + +let handler!: (request: Request) => Promise; +let dispose: () => Promise = async () => {}; + +beforeAll(async () => { + const { makeSelfHostTestApp, singleAdminIdentityLayer } = await import("./testing/test-app"); + const app = await makeSelfHostTestApp({ + identity: singleAdminIdentityLayer({ + userId: "admin", + organizationId: "default-org", + organizationName: "Default", + }), + }); + handler = app.handler; + dispose = app.dispose; +}); + +afterAll(() => dispose()); + +test("POST /api/oauth/probe hides CIMD when the deployment cannot serve the document", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + clientIdMetadataDocumentSupported: true, + }); + const res = yield* Effect.promise(() => + handler( + new Request("http://localhost/api/oauth/probe", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url: server.mcpResourceUrl }), + }), + ), + ); + expect(res.status).toBe(200); + const body = yield* Effect.promise(() => res.json()); + expect(body).toEqual( + expect.objectContaining({ + clientIdMetadataDocumentSupported: false, + registrationEndpoint: server.registrationEndpoint, + }), + ); + }), + ), + ); +}); diff --git a/e2e/RUNNING.md b/e2e/RUNNING.md index f16831add..fa0853b89 100644 --- a/e2e/RUNNING.md +++ b/e2e/RUNNING.md @@ -169,6 +169,59 @@ When handing results to the user, follow the evidence contract in the root [AGENTS.md](../AGENTS.md) (direct run links + a live instance + what to try); [RUNNING.md](../RUNNING.md) has the current sharing/demo mechanics. +## Docker OAuth deployment switch + +`selfhost-docker-cimd` is an opt-in browser suite for MCP and OpenAPI CIMD/DCR +selection. It creates isolated hosted emulator instances and restarts the same +Docker image and data volume with `EXECUTOR_OAUTH_CIMD_ENABLED=false`, then with +the variable absent. It checks provider registration, token exchange, and an +authenticated tool call through Executor, and records browser traces and ledgers. +The separate OpenAPI scenario removes a custom method while CIMD is disabled +and checks that restarting preserves the original OAuth configuration. + +Provide an explicit image, the dedicated test container port, and its reachable +web URL (the CIMD document must be reachable by the hosted authorization server): + +```sh +E2E_SELFHOST_DOCKER_IMAGE=executor-selfhost:e2e \ +E2E_SELFHOST_DOCKER_PORT=42885 \ +E2E_SELFHOST_DOCKER_URL=https://your-test-instance.example \ +bunx vitest run --project selfhost-docker-cimd +``` + +The initial container must already be running at that URL. The suite owns and +restarts `executor-e2e-selfhost-docker-`; use a dedicated synthetic test +instance. The hosted MCP emulator must implement the +`mcp.oauth.clientIdMetadataDocumentSupported` seed option. + +For explicitly authorized local emulator verification, set `E2E_CIMD_MCP_URL` +and `E2E_CIMD_OPENAPI_URL` to dedicated fresh emulator processes reachable from +both Docker and the browser. This attaches to those processes instead of creating +hosted instances; the same browser, token, and authenticated-operation assertions +still run. Runtime `/_emulate/seed` bodies contain the service configuration +directly, without the service-name wrapper used by startup configuration. + +The separate `selfhost-docker-cimd-legacy` project checks an upgrade from an +image that stored discovered CIMD templates without `discoveryUrl`. Set +`E2E_CIMD_LEGACY_IMAGE` to that older image and `E2E_SELFHOST_DOCKER_IMAGE` to the +image under review, using the same port, URL, and optional local provider settings +above. It uses the emulator's fault control to return 404 for protected-resource +metadata while retaining issuer discovery. The old image must create the template +and complete real CIMD authorization; the upgraded image must preserve the existing +connection and complete another authorization on the same integration. It also +checks persistence of the recovered URL and rejection of mismatched OAuth endpoints. +Set `E2E_CIMD_OPENAPI_PATH_URL` to a second emulator mounted at a path-based issuer +(e.g. `https://provider.example/tenant`) to run the same upgrade for both issuer shapes. + +```sh +E2E_CIMD_LEGACY_IMAGE=executor-cimd:before \ +E2E_CIMD_OPENAPI_PATH_URL=https://provider.example/tenant \ +E2E_SELFHOST_DOCKER_IMAGE=executor-cimd:legacy-fixed \ +E2E_SELFHOST_DOCKER_PORT=42905 \ +E2E_SELFHOST_DOCKER_URL=https://your-test-instance.example \ +bunx vitest run --project selfhost-docker-cimd-legacy +``` + ## Desktop targets (the app on real OSes, filmed) The packaged desktop app runs as its own targets, each landing in its own diff --git a/e2e/selfhost-docker/oauth-cimd-deployment.test.ts b/e2e/selfhost-docker/oauth-cimd-deployment.test.ts new file mode 100644 index 000000000..0e5894403 --- /dev/null +++ b/e2e/selfhost-docker/oauth-cimd-deployment.test.ts @@ -0,0 +1,322 @@ +import { randomBytes } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; + +import { runSelfhostContainer, stopSelfhostContainer } from "../setup/selfhost-docker.boot"; +import { createEmulatorInstance } from "../src/emulator-instance"; +import { e2ePort } from "../src/ports"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, RunDir, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import { SELFHOST_ADMIN } from "../targets/selfhost"; + +const api = composePluginApi([mcpHttpPlugin(), openApiHttpPlugin()] as const); + +// A new production container on the SAME named volume changes the actual +// process environment. No application config, database, or OAuth mocks. +const restart = (webBaseUrl: string, enabled: false | undefined) => + Effect.promise(async () => { + const image = + process.env.E2E_SELFHOST_DOCKER_RESOLVED_IMAGE ?? process.env.E2E_SELFHOST_DOCKER_IMAGE; + if (!image) throw new Error("The CIMD deployment scenario requires an explicit Docker image"); + const port = e2ePort("E2E_SELFHOST_DOCKER_PORT", 5); + await stopSelfhostContainer(port); + await runSelfhostContainer({ + image, + port, + webBaseUrl, + admin: SELFHOST_ADMIN, + oauthCimdEnabled: enabled, + publishPort: true, + }); + }); + +for (const protocol of ["MCP", "OpenAPI"] as const) { + scenario( + `Docker OAuth · ${protocol} uses DCR when CIMD is disabled and CIMD after restart`, + { timeout: 360_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const runDir = yield* RunDir; + const { client: makeClient } = yield* Api; + const service = protocol === "MCP" ? "mcp" : "posthog"; + const consentUser = protocol === "MCP" ? /admin@localhost/ : /cimd@example.com/; + const localUrl = + process.env[protocol === "MCP" ? "E2E_CIMD_MCP_URL" : "E2E_CIMD_OPENAPI_URL"]; + const baseUrl = localUrl ?? (yield* createEmulatorInstance(service, "oauth-deployment")); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + writeFileSync( + join(runDir, "ledger.json"), + JSON.stringify(await emulator.ledger.list(), null, 2), + ); + }).pipe(Effect.ignore), + ); + yield* Effect.promise(() => + emulator.seed( + service === "mcp" + ? { + oauth: { clientIdMetadataDocumentSupported: true }, + } + : { + users: [{ email: "cimd@example.com", name: "CIMD Test" }], + projects: [{ id: 1, name: "CIMD Project" }], + }, + ), + ); + const slug = IntegrationSlug.make(`cimd_${service}_${randomBytes(4).toString("hex")}`); + yield* restart(target.baseUrl, false); + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const clients = yield* client.oauth.listClients(); + for (const app of clients.filter((candidate) => + candidate.tokenUrl.startsWith(`${baseUrl}/`), + )) { + yield* client.oauth.removeClient({ + params: { slug: app.slug }, + payload: { owner: app.owner }, + }); + } + }).pipe(Effect.ignore), + ); + const resourceUrl = service === "mcp" ? `${baseUrl}/mcp` : baseUrl; + const probe = yield* client.oauth.probe({ payload: { url: resourceUrl } }); + expect(probe.clientIdMetadataDocumentSupported).toBe(false); + expect(probe.registrationEndpoint).toBeTruthy(); + + if (protocol === "MCP") { + yield* client.mcp.addServer({ + payload: { + slug, + name: "CIMD deployment MCP", + transport: "remote", + endpoint: resourceUrl, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + } else { + yield* client.openapi.addSpec({ + payload: { + slug, + name: "CIMD deployment OpenAPI", + spec: { kind: "url", url: emulator.openapiUrl }, + }, + }); + } + yield* Effect.addFinalizer(() => + (protocol === "MCP" + ? client.mcp.removeServer({ params: { slug } }) + : client.openapi.removeSpec({ params: { slug } }) + ).pipe(Effect.ignore), + ); + + // Keep a single browser session so the recording includes the restart + // and both authorizations for the integration created while disabled. + yield* browser.session(identity, async ({ page, step }) => { + for (const flow of ["dcr", "cimd"] as const) { + if (flow === "cimd") { + await step("Restart Docker with EXECUTOR_OAUTH_CIMD_ENABLED unset", async () => { + await Effect.runPromise(restart(target.baseUrl, undefined)); + }); + } + const effective = await Effect.runPromise( + client.oauth.probe({ payload: { url: resourceUrl } }), + ); + expect( + effective.clientIdMetadataDocumentSupported, + "the deployment switch controls the live CIMD capability", + ).toBe(flow === "cimd"); + await emulator.ledger.clear(); + const before = await Effect.runPromise( + client.connections.list({ query: { integration: slug } }), + ); + await step(`Connect ${protocol} using ${flow.toUpperCase()}`, async () => { + await visit(page, `/integrations/${slug}`); + await page + .getByRole("button", { name: "Add connection", exact: true }) + .first() + .click(); + if (protocol === "OpenAPI") await page.getByRole("tab", { name: /OAuth/ }).click(); + const connect = page + .getByRole("dialog") + .getByRole("button", { name: /^(Connect|Connect with OAuth)$/ }); + await connect.waitFor({ timeout: 15_000 }); + const [popup] = await Promise.all([page.waitForEvent("popup"), connect.click()]); + await popup.getByRole("button", { name: consentUser }).waitFor({ timeout: 30_000 }); + const authorization = new URL(popup.url()); + const clientId = authorization.searchParams.get("client_id") ?? ""; + expect( + clientId.includes("/api/oauth/client-id-metadata/"), + "the real authorization request uses the expected client identity", + ).toBe(flow === "cimd"); + await Promise.all([ + popup.waitForEvent("close", { timeout: 60_000 }), + popup.getByRole("button", { name: consentUser }).click(), + ]); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 60_000 }); + }); + + await step( + `Call the authenticated ${protocol} operation through Executor`, + async () => { + const after = await Effect.runPromise( + client.connections.list({ query: { integration: slug } }), + ); + const connection = after.find( + (candidate) => + !before.some( + (previous) => + previous.name === candidate.name && previous.owner === candidate.owner, + ), + ); + expect(connection, "OAuth callback persisted a new connection").toBeDefined(); + const tools = await Effect.runPromise( + client.tools.list({ query: { integration: slug } }), + ); + const tool = tools.find( + (candidate) => + candidate.connection === connection?.name && + (protocol === "MCP" + ? String(candidate.address).endsWith("get_me") + : String(candidate.address).endsWith("projectsList")), + ); + expect( + tool, + `the authenticated operation exists on the new connection: ${tools.map((item) => item.address).join(", ")}`, + ).toBeDefined(); + const session = mcp.session(identity); + let result = await Effect.runPromise( + session.call("execute", { + code: `return await ${tool?.address}({});`, + }), + ); + for ( + let approval = 0; + approval < 10 && result.text.includes("executionId:"); + approval++ + ) { + result = await Effect.runPromise(session.approvePaused(result.text)); + } + expect(result.ok, result.text).toBe(true); + expect(result.text).toContain(protocol === "MCP" ? "admin" : "CIMD Project"); + const ledger = await emulator.ledger.list(); + writeFileSync(join(runDir, `${flow}-ledger.json`), JSON.stringify(ledger, null, 2)); + expect( + ledger.some( + (entry) => + entry.method === "POST" && + /\/register\/?$/.test(entry.path) && + entry.response.status === 201, + ), + ).toBe(flow === "dcr"); + expect( + ledger.some( + (entry) => + entry.method === "POST" && + /\/token\/?$/.test(entry.path) && + entry.response.status === 200, + ), + ).toBe(true); + expect( + ledger.some( + (entry) => + entry.response.status === 200 && + entry.identity.user?.login === + (protocol === "MCP" ? "admin" : "cimd@example.com") && + (protocol === "MCP" + ? entry.method === "POST" && + entry.path.endsWith("/mcp") && + JSON.stringify(entry.request.body).includes("tools/call") + : entry.method === "GET" && entry.path.endsWith("/api/projects/")), + ), + ).toBe(true); + }, + ); + } + }); + }), + ), + ); +} + +scenario( + "Docker OAuth · removing a custom OpenAPI method preserves CIMD across restart", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const baseUrl = + process.env.E2E_CIMD_OPENAPI_URL ?? + (yield* createEmulatorInstance("posthog", "oauth-config")); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + yield* restart(target.baseUrl, undefined); + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`cimd_config_${randomBytes(4).toString("hex")}`); + yield* client.openapi.addSpec({ + payload: { slug, spec: { kind: "url", url: emulator.openapiUrl } }, + }); + yield* Effect.addFinalizer(() => + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + const original = yield* client.openapi.getConfig({ params: { slug } }); + expect(original?.authenticationTemplate?.some((template) => template.kind === "oauth2")).toBe( + true, + ); + yield* client.openapi.configure({ + params: { slug }, + payload: { + authenticationTemplate: [ + { + type: "apiKey", + slug: "custom_temporary", + label: "Temporary key", + headers: { "x-test-key": "{{token}}" }, + }, + ], + }, + }); + yield* browser.session(identity, async ({ page, step }) => { + await step("Disable CIMD and restart the Docker container", () => + Effect.runPromise(restart(target.baseUrl, false)), + ); + await step("Remove an unrelated custom authentication method", async () => { + await visit(page, `/integrations/${slug}`); + await page.getByRole("button", { name: "Add connection", exact: true }).first().click(); + await page.getByRole("tab", { name: "Temporary key" }).click(); + await page.getByRole("button", { name: "Remove Temporary key", exact: true }).click(); + await page.getByRole("tab", { name: "Temporary key" }).waitFor({ state: "hidden" }); + }); + await step( + "Unset the deployment switch and verify the original OAuth configuration", + async () => { + await Effect.runPromise(restart(target.baseUrl, undefined)); + const restored = await Effect.runPromise( + client.openapi.getConfig({ params: { slug } }), + ); + expect(restored?.authenticationTemplate).toEqual(original?.authenticationTemplate); + await visit(page, `/integrations/${slug}`); + }, + ); + }); + }), + ), +); diff --git a/e2e/selfhost-docker/oauth-cimd-legacy.test.ts b/e2e/selfhost-docker/oauth-cimd-legacy.test.ts new file mode 100644 index 000000000..578c1b82c --- /dev/null +++ b/e2e/selfhost-docker/oauth-cimd-legacy.test.ts @@ -0,0 +1,287 @@ +import { randomBytes } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { runSelfhostContainer, stopSelfhostContainer } from "../setup/selfhost-docker.boot"; +import { createEmulatorInstance } from "../src/emulator-instance"; +import { e2ePort } from "../src/ports"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, RunDir, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; +import { SELFHOST_ADMIN } from "../targets/selfhost"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +for (const issuer of ["root", "path"] as const) { + scenario( + `Docker OAuth · issuer-only legacy OpenAPI survives upgrade (${issuer} issuer)`, + { timeout: 360_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const mcp = yield* Mcp; + const runDir = yield* RunDir; + const { client: makeClient } = yield* Api; + const legacyImage = process.env.E2E_CIMD_LEGACY_IMAGE; + const currentImage = process.env.E2E_SELFHOST_DOCKER_IMAGE; + if (!legacyImage || !currentImage) { + return yield* Effect.die( + new Error("Explicit legacy and current Docker images are required"), + ); + } + const reboot = (image: string) => + Effect.promise(async () => { + const port = e2ePort("E2E_SELFHOST_DOCKER_PORT", 5); + await stopSelfhostContainer(port); + await runSelfhostContainer({ + image, + port, + webBaseUrl: target.baseUrl, + admin: SELFHOST_ADMIN, + publishPort: true, + }); + }); + if (issuer === "path" && !process.env.E2E_CIMD_OPENAPI_PATH_URL) { + return yield* Effect.die( + new Error("E2E_CIMD_OPENAPI_PATH_URL is required for path-issuer coverage"), + ); + } + const baseUrl = + process.env[issuer === "root" ? "E2E_CIMD_OPENAPI_URL" : "E2E_CIMD_OPENAPI_PATH_URL"] ?? + (yield* createEmulatorInstance("posthog", "cimd-legacy")); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + writeFileSync( + join(runDir, "ledger.json"), + JSON.stringify(await emulator.ledger.list(), null, 2), + ); + await emulator.faults.clear(); + }).pipe(Effect.ignore), + ); + yield* Effect.promise(async () => { + await emulator.seed({ + users: [{ email: "legacy@example.com", name: "Legacy Test" }], + projects: [{ id: 1, name: "Legacy Project" }], + }); + // Use the emulator's real fault control to model an AS-only provider. + // Authorization, CIMD document fetches, tokens, and API calls stay real. + await emulator.faults.arm({ + match: { method: "GET", pathPattern: "/.well-known/oauth-protected-resource*" }, + response: { status: 404, body: { error: "not_found" } }, + times: 1000, + }); + }); + yield* reboot(legacyImage); + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`cimd_legacy_${randomBytes(4).toString("hex")}`); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* client.openapi.removeSpec({ params: { slug } }); + for (const app of yield* client.oauth.listClients()) { + if (app.tokenUrl.startsWith(`${baseUrl}/`)) { + yield* client.oauth.removeClient({ + params: { slug: app.slug }, + payload: { owner: app.owner }, + }); + } + } + }).pipe(Effect.ignore), + ); + const probe = yield* client.oauth.probe({ payload: { url: baseUrl } }); + expect(probe.clientIdMetadataDocumentSupported).toBe(true); + expect(probe.resource).toBeNull(); + expect(probe.issuer).toBe(baseUrl); + yield* client.openapi.addSpec({ + payload: { + slug, + name: "Legacy issuer-only OpenAPI", + spec: { kind: "url", url: emulator.openapiUrl }, + }, + }); + // The old image creates this shape through normal discovery; no stored + // configuration, OAuth clients, or tokens are fabricated for the upgrade. + const original = yield* client.openapi.getConfig({ params: { slug } }); + const oauth = original?.authenticationTemplate?.find((method) => method.kind === "oauth2"); + expect(oauth).toMatchObject({ + kind: "oauth2", + resource: null, + supportsClientIdMetadataDocument: true, + }); + expect(oauth).not.toHaveProperty("discoveryUrl"); + writeFileSync(join(runDir, "legacy-config.json"), JSON.stringify(original, null, 2)); + + yield* browser.session(identity, async ({ page, step }) => { + const callProjects = async (connectionName?: string) => { + const tools = await Effect.runPromise( + client.tools.list({ query: { integration: slug } }), + ); + const tool = tools.find( + (item) => + String(item.address).endsWith("projectsList") && + (connectionName === undefined || item.connection === connectionName), + ); + expect(tool).toBeDefined(); + const session = mcp.session(identity); + let result = await Effect.runPromise( + session.call("execute", { + code: `return await ${tool?.address}({});`, + }), + ); + for (let i = 0; i < 10 && result.text.includes("executionId:"); i++) { + result = await Effect.runPromise(session.approvePaused(result.text)); + } + expect(result.ok, result.text).toBe(true); + expect(result.text).toContain("Legacy Project"); + const ledger = await emulator.ledger.list(); + expect( + ledger.some( + (entry) => + entry.path === "/api/projects/" && + entry.response.status === 200 && + entry.identity.user?.login === "legacy@example.com", + ), + ).toBe(true); + }; + for (const phase of ["legacy", "upgraded"] as const) { + if (phase === "upgraded") { + await step("Upgrade the same Docker volume with CIMD still enabled", async () => { + await Effect.runPromise(reboot(currentImage)); + const restored = await Effect.runPromise( + client.openapi.getConfig({ params: { slug } }), + ); + writeFileSync( + join(runDir, "upgraded-config.json"), + JSON.stringify(restored, null, 2), + ); + }); + await emulator.ledger.clear(); + await step("Call projects using the existing connection after upgrade", () => + callProjects(), + ); + writeFileSync( + join(runDir, "existing-connection-ledger.json"), + JSON.stringify(await emulator.ledger.list(), null, 2), + ); + } + const stored = await Effect.runPromise(client.openapi.getConfig({ params: { slug } })); + expect(stored?.authenticationTemplate).toEqual(original?.authenticationTemplate); + await emulator.ledger.clear(); + const before = await Effect.runPromise( + client.connections.list({ query: { integration: slug } }), + ); + await step(`Connect with CIMD on the ${phase} image`, async () => { + await visit(page, `/integrations/${slug}`); + await page + .getByRole("button", { name: "Add connection", exact: true }) + .first() + .click(); + await page.getByRole("tab", { name: /OAuth/ }).click(); + const [popup] = await Promise.all([ + page.waitForEvent("popup"), + page + .getByRole("dialog") + .getByRole("button", { name: /^(Connect|Connect with OAuth)$/ }) + .click(), + ]); + await popup + .getByRole("button", { name: /legacy@example.com/ }) + .waitFor({ timeout: 30_000 }); + expect(new URL(popup.url()).searchParams.get("client_id")).toContain( + "/api/oauth/client-id-metadata/", + ); + await Promise.all([ + popup.waitForEvent("close", { timeout: 60_000 }), + popup.getByRole("button", { name: /legacy@example.com/ }).click(), + ]); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 60_000 }); + }); + const after = await Effect.runPromise( + client.connections.list({ query: { integration: slug } }), + ); + const connection = after.find( + (item) => + !before.some( + (previous) => previous.name === item.name && previous.owner === item.owner, + ), + ); + expect(connection, "CIMD created a new connection").toBeDefined(); + await step(`Call projects after ${phase} CIMD authorization`, () => + callProjects(connection?.name), + ); + const ledger = await emulator.ledger.list(); + writeFileSync(join(runDir, `${phase}-ledger.json`), JSON.stringify(ledger, null, 2)); + expect( + ledger.some( + (entry) => + entry.path === "/oauth/token/" && + entry.method === "POST" && + entry.response.status === 200, + ), + ).toBe(true); + expect( + ledger.some((entry) => entry.path === "/oauth/register/" && entry.method === "POST"), + ).toBe(false); + } + }); + const recovered = yield* client.openapi.getConfig({ params: { slug } }); + expect(recovered?.authenticationTemplate).toEqual( + original?.authenticationTemplate?.map((method) => + method.kind === "oauth2" ? { ...method, discoveryUrl: new URL(baseUrl).href } : method, + ), + ); + writeFileSync(join(runDir, "recovered-config.json"), JSON.stringify(recovered, null, 2)); + // A caller holding the original catalog URL must also work after the + // migration persisted a better candidate from the spec's servers. + const reprobe = yield* client.oauth.probe({ + payload: { + url: original?.specUrl ?? baseUrl, + integration: slug, + template: AuthTemplateSlug.make("oauth-DiscoveredOAuth2"), + }, + }); + expect(reprobe).toMatchObject({ issuer: baseUrl, clientIdMetadataDocumentSupported: true }); + // The API/SDK uses the same recovery and must reject a different endpoint + // pair, even when a candidate serves valid metadata on the same origin. + yield* client.openapi.configure({ + params: { slug }, + payload: { + mode: "replace", + authenticationTemplate: original?.authenticationTemplate?.flatMap((method) => + method.kind === "oauth2" ? [{ ...method, tokenUrl: `${baseUrl}/other/token` }] : [], + ), + }, + }); + const rejected = yield* client.oauth + .probe({ + payload: { + url: baseUrl, + integration: slug, + template: AuthTemplateSlug.make("oauth-DiscoveredOAuth2"), + }, + }) + .pipe(Effect.result); + expect(rejected).toMatchObject({ _tag: "Failure", failure: { _tag: "OAuthProbeError" } }); + writeFileSync( + join(runDir, "probe-validation.json"), + JSON.stringify({ reprobe, rejected }, null, 2), + ); + const rejectedConfig = yield* client.openapi.getConfig({ params: { slug } }); + expect( + rejectedConfig?.authenticationTemplate?.find((method) => method.kind === "oauth2"), + ).not.toHaveProperty("discoveryUrl"); + }), + ), + ); +} diff --git a/e2e/setup/selfhost-docker.boot.ts b/e2e/setup/selfhost-docker.boot.ts index 67f49d0fb..265f19c51 100644 --- a/e2e/setup/selfhost-docker.boot.ts +++ b/e2e/setup/selfhost-docker.boot.ts @@ -74,6 +74,9 @@ export interface RunContainerOptions { readonly webBaseUrl: string; readonly admin: { readonly email: string; readonly password: string }; readonly logFile?: string; + readonly oauthCimdEnabled?: boolean; + /** Hosted emulators need no access to the runner's loopback helper servers. */ + readonly publishPort?: boolean; } /** @@ -90,8 +93,9 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis "--detach", "--name", name, - "--network", - "host", + ...(options.publishPort + ? ["--publish", `127.0.0.1:${options.port}:${options.port}`] + : ["--network", "host"]), "--volume", `${volume}:/data`, "-e", @@ -108,6 +112,9 @@ export const runSelfhostContainer = async (options: RunContainerOptions): Promis // test servers and points the instance at them. "-e", "EXECUTOR_ALLOW_LOCAL_NETWORK=true", + ...(options.oauthCimdEnabled === undefined + ? [] + : ["-e", `EXECUTOR_OAUTH_CIMD_ENABLED=${options.oauthCimdEnabled}`]), options.image, ]; log(options.logFile, `docker ${args.join(" ")}`); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 38d1e7965..afa7cb5a1 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -10,5 +10,15 @@ "jsx": "react-jsx", "types": ["node"] }, - "include": ["src", "cloud", "scenarios", "selfhost", "setup", "targets", "scripts", "viewer/src"] + "include": [ + "src", + "cloud", + "scenarios", + "selfhost", + "selfhost-docker", + "setup", + "targets", + "scripts", + "viewer/src" + ] } diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 6a050c738..8066fbfc1 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -38,6 +38,20 @@ export default defineConfig({ include: ["scenarios/**/*.test.ts", "selfhost/**/*.test.ts"], fileParallelism: false, }), + // Opt-in deployment cycles against hosted OAuth providers: the attached + // Docker instance needs a public HTTPS URL for real CIMD document fetches. + project("selfhost-docker-cimd", { + include: ["selfhost-docker/oauth-cimd-deployment.test.ts"], + env: { E2E_TARGET: "selfhost-docker" }, + globalSetup: ["./setup/selfhost-docker.globalsetup.ts"], + fileParallelism: false, + }), + project("selfhost-docker-cimd-legacy", { + include: ["selfhost-docker/oauth-cimd-legacy.test.ts"], + env: { E2E_TARGET: "selfhost-docker" }, + globalSetup: ["./setup/selfhost-docker.globalsetup.ts"], + fileParallelism: false, + }), // The Cloudflare self-host worker (workerd via wrangler dev, dev-auth). // Scoped to the cross-target scenarios wired for this host; the rest of // scenarios/** is not yet validated against the worker. The full-graph diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index eb4b1f939..82536dd0f 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -199,7 +199,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler capture( Effect.gen(function* () { const executor = yield* ExecutorService; - return yield* executor.oauth.probe({ url: payload.url }); + return yield* executor.oauth.probe(payload); }), ), ) diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 5d0eee3d4..55aab6c9d 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -228,6 +228,8 @@ const CancelResponse = Schema.Struct({ const ProbePayload = Schema.Struct({ url: Schema.String, + integration: Schema.optional(IntegrationSlug), + template: Schema.optional(AuthTemplateSlug), }); const ProbeResponse = Schema.Struct({ diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 749839be3..4f180e32c 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -118,6 +118,11 @@ export interface HostConfigShape { * attempted as it was before the gate existed. */ readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; + /** + * Forwarded verbatim to `ExecutorConfig.oauthClientIdMetadataDocumentEnabled`. + * Omit to keep the SDK default (enabled). + */ + readonly oauthClientIdMetadataDocumentEnabled?: boolean; /** * Forwarded verbatim to `ExecutorConfig.toolsSyncTtlMs`: how long a * connection's persisted remote tool catalog stays fresh. Omit to take the @@ -341,6 +346,7 @@ export const makeScopedExecutor = < oauthCallbackStateOrgSlug: orgSlug, firstPartyOAuthClients: config.firstPartyOAuthClients, enterpriseManagedRollout: config.enterpriseManagedRollout, + oauthClientIdMetadataDocumentEnabled: config.oauthClientIdMetadataDocumentEnabled, coreTools: { webBaseUrl, orgSlug, diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 461e04d8a..af7609137 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -288,6 +288,8 @@ const OAuthRemoveClientInput = Schema.Struct({ }); const OAuthProbeInput = Schema.Struct({ url: Schema.String, + integration: Schema.optional(IntegrationSlug), + template: Schema.optional(AuthTemplateSlug), }); const OAuthProbeOutput = Schema.Struct({ issuer: Schema.optional(Schema.NullOr(Schema.String)), @@ -916,11 +918,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "oauth.probe", description: - "Discover OAuth authorization-server metadata from an issuer or protected-resource URL so client registration can be pre-filled.", + "Discover OAuth authorization-server metadata from an issuer or protected-resource URL. Pass integration and template when connecting a catalog method so legacy discovery can be recovered.", inputSchema: OAuthProbeInputStd, outputSchema: OAuthProbeOutputStd, execute: (input: typeof OAuthProbeInput.Type, { ctx }) => - Effect.map(ctx.oauth.probe({ url: input.url }), (result) => ({ + Effect.map(ctx.oauth.probe(input), (result) => ({ issuer: result.issuer ?? null, authorizationUrl: result.authorizationUrl, tokenUrl: result.tokenUrl, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index cd89ab907..415c0c4ba 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -766,6 +766,17 @@ export interface ExecutorConfig(); + // A deployment that cannot serve its CIMD must not offer it in the catalog. + const maskClientIdMetadataDocument = (method: AuthMethodDescriptor): AuthMethodDescriptor => { + if (clientIdMetadataDocumentEnabled) return method; + if (method.kind !== "oauth" || !method.oauth?.supportsClientIdMetadataDocument) return method; + return { ...method, oauth: { ...method.oauth, supportsClientIdMetadataDocument: false } }; + }; const describeAuthMethodsForRow = ( row: IntegrationRow, ): Effect.Effect => @@ -3161,7 +3179,7 @@ export const createExecutor = + Effect.gen(function* () { + if (input.integration && input.template) { + const row = yield* findIntegrationRow(input.integration); + const runtime = row ? runtimes.get(row.plugin_id) : undefined; + if (row && runtime?.plugin.recoverOAuthDiscovery) { + const recovered = yield* runtime.plugin.recoverOAuthDiscovery({ + ctx: runtime.ctx, + integration: rowToIntegrationRecord(row), + template: input.template, + }); + if (recovered) return recovered; + } + } + return yield* oauthService.probe(input); + }), + }; + const blobPartitions: OwnerPartitions = { org: `o:${tenant}`, user: subject != null ? `u:${tenant}:${subject}` : null, diff --git a/packages/core/sdk/src/integration.ts b/packages/core/sdk/src/integration.ts index e1ac53b39..34e16c908 100644 --- a/packages/core/sdk/src/integration.ts +++ b/packages/core/sdk/src/integration.ts @@ -61,9 +61,10 @@ export interface AuthMethodOAuthDescriptor { /** True when the integration is known to support RFC 7591 dynamic client * registration (drives the transparent auto-register connect flow). */ readonly supportsDynamicRegistration?: boolean; - /** True when the authorization server supports Client ID Metadata Document - * clients. The UI can create a local public OAuth client using this host's - * metadata-document URL as `client_id`, with no provider app registration. */ + /** Client ID Metadata Document support declared by the plugin. Catalog reads + * apply `ExecutorConfig.oauthClientIdMetadataDocumentEnabled` before exposing + * this flag. When true, the UI can use this host's metadata-document URL as + * `client_id`, with no provider app registration. */ readonly supportsClientIdMetadataDocument?: boolean; /** The enterprise identity provider this integration is configured to obtain * identity assertions from (MCP Enterprise-Managed Authorization). Present diff --git a/packages/core/sdk/src/oauth-cimd-capability.test.ts b/packages/core/sdk/src/oauth-cimd-capability.test.ts new file mode 100644 index 000000000..ebd72594d --- /dev/null +++ b/packages/core/sdk/src/oauth-cimd-capability.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { IntegrationSlug } from "./ids"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +const INTEG = IntegrationSlug.make("acme"); + +const AUTHORIZATION_URL = "https://as.example/authorize"; +const TOKEN_URL = "https://as.example/token"; + +const cimdPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth", + kind: "oauth" as const, + template: "oauth", + oauth: { + authorizationUrl: AUTHORIZATION_URL, + tokenUrl: TOKEN_URL, + supportsClientIdMetadataDocument: true, + }, + }, + ], + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Acme", + config: {}, + }), + }), +}))(); + +const plugins = [memoryCredentialsPlugin(), cimdPlugin] as const; + +describe("oauth Client ID Metadata Document deployment capability", () => { + for (const enabled of [undefined, false]) { + it.effect(`probe reports the deployment capability (enabled: ${enabled})`, () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ + clientIdMetadataDocumentSupported: true, + }); + const { executor } = yield* makeTestWorkspaceHarness({ + oauthClientIdMetadataDocumentEnabled: enabled, + }); + + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); + expect(probe.clientIdMetadataDocumentSupported).toBe(enabled ?? true); + expect(probe.registrationEndpoint).toBe(server.registrationEndpoint); + }), + ), + ); + + it.effect(`catalog oauth methods reflect the deployment capability (enabled: ${enabled})`, () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + oauthClientIdMetadataDocumentEnabled: enabled, + }); + yield* executor.acme.seed(); + + const integration = yield* executor.integrations.get(INTEG); + expect(integration?.authMethods).toEqual([ + { + id: "oauth", + label: "OAuth", + kind: "oauth", + template: "oauth", + oauth: { + authorizationUrl: AUTHORIZATION_URL, + tokenUrl: TOKEN_URL, + supportsClientIdMetadataDocument: enabled ?? true, + }, + }, + ]); + }), + ), + ); + } +}); diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 877820501..dead30602 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -117,6 +117,8 @@ export interface OAuthAuthentication { * `client_id` is this host's metadata-document URL, not a provider-side * registered app id. */ readonly supportsClientIdMetadataDocument?: boolean; + /** Endpoint to re-probe before choosing CIMD or dynamic registration. */ + readonly discoveryUrl?: string; } /** A registered OAuth app — pure app identity: clientId/secret + its endpoints. @@ -387,6 +389,9 @@ export interface OAuthCompleteOptions { * onboarding UI can pre-fill a client's endpoints. */ export interface OAuthProbeInput { readonly url: string; + /** Resolve discovery from a stored integration method when available. */ + readonly integration?: IntegrationSlug; + readonly template?: AuthTemplateSlug; } export interface OAuthProbeResult { @@ -404,8 +409,9 @@ export interface OAuthProbeResult { /** RFC 8414 `token_endpoint_auth_methods_supported`. Surfaced so DCR can pick * a public ("none") client when the server allows it. */ readonly tokenEndpointAuthMethodsSupported?: readonly string[]; - /** Draft OAuth Client ID Metadata Document support, advertised by providers - * such as PostHog as `client_id_metadata_document_supported`. */ + /** Effective OAuth Client ID Metadata Document support from discovery, not + * raw `client_id_metadata_document_supported` metadata. See + * `ExecutorConfig.oauthClientIdMetadataDocumentEnabled` for deployment gating. */ readonly clientIdMetadataDocumentSupported?: boolean; } diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ba91260a5..844484943 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -308,6 +308,9 @@ export interface OAuthServiceDeps { * client CRUD surface rejects the namespace. Empty/omitted on hosts that * ship no first-party apps. */ readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[]; + /** Resolved `ExecutorConfig.oauthClientIdMetadataDocumentEnabled`; + * see that public configuration contract for probe behavior. */ + readonly clientIdMetadataDocumentEnabled: boolean; } type LooseDb = { @@ -2559,6 +2562,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { registrationEndpoint: as.metadata.registration_endpoint ?? null, tokenEndpointAuthMethodsSupported: as.metadata.token_endpoint_auth_methods_supported, clientIdMetadataDocumentSupported: + deps.clientIdMetadataDocumentEnabled && as.metadata.client_id_metadata_document_supported === true, } satisfies OAuthProbeResult; }).pipe(Effect.provide(httpClientLayer)); diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index 2ace32f89..a177b4a77 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -50,7 +50,7 @@ import type { InvalidConnectionInputError, OrgWriteDeniedError, } from "./errors"; -import type { OAuthService } from "./oauth-client"; +import type { OAuthService, OAuthProbeResult, OAuthProbeError } from "./oauth-client"; import type { CredentialProvider, ProviderEntry } from "./provider"; import type { PluginStorageConfig, PluginStorageFacade } from "./plugin-storage"; import type { @@ -651,6 +651,8 @@ export type IntegrationPresetAuthentication = readonly resource?: string | null; readonly scopes: readonly string[]; readonly supportsClientIdMetadataDocument?: boolean; + /** Endpoint to re-probe before choosing CIMD or dynamic registration. */ + readonly discoveryUrl?: string; } | { readonly kind: "apiKey"; @@ -798,6 +800,14 @@ export interface PluginSpec< /** Core-dispatched integration configuration (beyond auth). */ readonly integrationConfigure?: IntegrationConfigureDecl; + /** Recover discovery for a legacy stored OAuth method at connect time. + * Return null when ordinary URL discovery should handle the request. */ + readonly recoverOAuthDiscovery?: (input: { + readonly ctx: PluginCtx; + readonly integration: IntegrationRecord; + readonly template: AuthTemplateSlug; + }) => Effect.Effect; + /** Project this plugin's opaque integration config into catalog-visible * declared auth methods. Synchronous and pure (the config is already loaded); * must tolerate a malformed/foreign config blob by returning `[]`. Absent ⇒ diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index df32bcda0..3e509dc85 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -133,6 +133,7 @@ export type TestConfigOptions["onIntegrationChange"]; readonly firstPartyOAuthClients?: ExecutorConfig["firstPartyOAuthClients"]; + readonly oauthClientIdMetadataDocumentEnabled?: boolean; readonly enterpriseManagedRollout?: ExecutorConfig["enterpriseManagedRollout"]; /** Workspace-settings permission for the test binding (see * `ExecutorConfig.orgWrites`). Defaults to allowed, like production hosts @@ -181,6 +182,7 @@ export const makeTestConfig = (() => { - const declared = authMethodsFromConfig(existingTemplate); + const declared = authMethodsFromConfig( + existingTemplate, + AsyncResult.isSuccess(configResult) ? (configResult.value ?? undefined) : undefined, + ); return declared.length > 0 ? declared : [NO_AUTH_METHOD]; - }, [existingTemplate]); + }, [existingTemplate, configResult]); // Custom-method create/remove: the shared skeleton (merge-append → diff out // the created method; filter → replace) parameterized by the OpenAPI codec. @@ -93,14 +96,18 @@ export default function OpenApiAccountsPanel(props: { const codec = useMemo>( () => ({ - toAuthMethods: authMethodsFromConfig, + toAuthMethods: (templates) => + authMethodsFromConfig( + templates, + AsyncResult.isSuccess(configResult) ? (configResult.value ?? undefined) : undefined, + ), // Slug omitted → backend backfills `custom_`. templatesFromPlacements: (placements: readonly Placement[]) => [ templateFromPlacements(placements), ], slugOf: (template: Authentication) => String(template.slug), }), - [], + [configResult], ); const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ diff --git a/packages/plugins/openapi/src/react/auth-method-config.test.ts b/packages/plugins/openapi/src/react/auth-method-config.test.ts index 89aec1be1..b54faf48e 100644 --- a/packages/plugins/openapi/src/react/auth-method-config.test.ts +++ b/packages/plugins/openapi/src/react/auth-method-config.test.ts @@ -10,6 +10,24 @@ import { import type { Authentication } from "../sdk/types"; describe("authMethodsFromConfig", () => { + it("uses the original base URL for issuer-only legacy discovery", () => { + const methods = authMethodsFromConfig( + [ + { + kind: "oauth2", + slug: AuthTemplateSlug.make("oauth-DiscoveredOAuth2"), + authorizationUrl: "https://provider.example/tenant/oauth/authorize", + tokenUrl: "https://provider.example/tenant/oauth/token", + resource: null, + scopes: [], + supportsClientIdMetadataDocument: true, + }, + ], + { baseUrl: "https://provider.example/tenant" }, + ); + expect(methods[0]?.oauth?.discoveryUrl).toBe("https://provider.example/tenant"); + }); + it("projects oauth templates with their stored endpoints + scopes", () => { const methods = authMethodsFromConfig([ { @@ -20,6 +38,7 @@ describe("authMethodsFromConfig", () => { resource: "https://api.example", scopes: ["read"], supportsClientIdMetadataDocument: true, + discoveryUrl: "https://api.example", }, ]); expect(methods[0]).toMatchObject({ @@ -32,10 +51,25 @@ describe("authMethodsFromConfig", () => { resource: "https://api.example", scopes: ["read"], supportsClientIdMetadataDocument: true, + discoveryUrl: "https://api.example", }, }); }); + it("re-probes older discovered OAuth templates even without a stored CIMD flag", () => { + const [method] = authMethodsFromConfig([ + { + slug: AuthTemplateSlug.make("oauth-DiscoveredOAuth2"), + kind: "oauth2", + authorizationUrl: "https://x.example/auth", + tokenUrl: "https://x.example/token", + resource: "https://api.example", + scopes: [], + }, + ]); + expect(method?.oauth?.discoveryUrl).toBe("https://api.example"); + }); + it("projects apikey methods, multi-placement and multi-variable intact", () => { const methods = authMethodsFromConfig([ { @@ -80,6 +114,7 @@ describe("editor round-trip", () => { resource: "https://api.example", scopes: ["a", "b"], supportsClientIdMetadataDocument: true, + discoveryUrl: "https://api.example", }), ).toEqual({ kind: "oauth", @@ -88,6 +123,7 @@ describe("editor round-trip", () => { resource: "https://api.example", scopes: ["a", "b"], supportsClientIdMetadataDocument: true, + discoveryUrl: "https://api.example", }); }); @@ -96,6 +132,7 @@ describe("editor round-trip", () => { slug: AuthTemplateSlug.make("azureAdDelegated"), kind: "oauth2", label: "OAuth2 (user)", + discoveryUrl: "https://api.example", authorizationUrl: "https://x.example/auth", tokenUrl: "https://x.example/token", resource: null, diff --git a/packages/plugins/openapi/src/react/auth-method-config.ts b/packages/plugins/openapi/src/react/auth-method-config.ts index 764c23d79..40ffdd2e3 100644 --- a/packages/plugins/openapi/src/react/auth-method-config.ts +++ b/packages/plugins/openapi/src/react/auth-method-config.ts @@ -17,6 +17,8 @@ import { wirePlacementsFromEditor, } from "@executor-js/react/lib/shared-auth-method-codec"; +import { openApiOAuthDiscoveryUrl, type OpenApiIntegrationConfig } from "../sdk/config"; + import type { APIKeyAuthentication, Authentication, AuthenticationInput } from "../sdk/types"; /** Serialize a canonical method into the wire input union (apikey → the @@ -27,7 +29,10 @@ export const openApiWireAuthInput = (method: Authentication): AuthenticationInpu export const placementsFromApiKey = (template: APIKeyAuthentication): readonly Placement[] => editorPlacementsFromWire(template.placements); -const oauthAuthMethod = (template: Extract): AuthMethod => { +const oauthAuthMethod = ( + template: Extract, + config?: Pick, +): AuthMethod => { const slug = String(template.slug); return { id: slug, @@ -44,14 +49,18 @@ const oauthAuthMethod = (template: Extract): resource: template.resource ?? null, scopes: template.scopes, supportsClientIdMetadataDocument: template.supportsClientIdMetadataDocument, + discoveryUrl: openApiOAuthDiscoveryUrl(template, config), }, }; }; /** Map each stored auth template to a generic `AuthMethod`. */ -export function authMethodsFromConfig(templates: readonly Authentication[]): AuthMethod[] { +export function authMethodsFromConfig( + templates: readonly Authentication[], + config?: Pick, +): AuthMethod[] { return templates.map((template: Authentication): AuthMethod => { - if (template.kind === "oauth2") return oauthAuthMethod(template); + if (template.kind === "oauth2") return oauthAuthMethod(template, config); return authMethodFromSharedTemplate(template); }); } @@ -84,6 +93,7 @@ export function editorValueFromAuthentication(template: Authentication): AuthTem resource: template.resource ?? null, scopes: template.scopes ?? [], supportsClientIdMetadataDocument: template.supportsClientIdMetadataDocument, + discoveryUrl: template.discoveryUrl, }; } return editorValueFromSharedMethod(template); @@ -101,6 +111,7 @@ const oauthTemplateFromEditorValue = ( tokenUrl: value.tokenUrl, resource: value.resource ?? null, scopes: [...value.scopes], + ...(value.discoveryUrl ? { discoveryUrl: value.discoveryUrl } : {}), ...(value.supportsClientIdMetadataDocument === true ? { supportsClientIdMetadataDocument: true } : {}), diff --git a/packages/plugins/openapi/src/sdk/config.ts b/packages/plugins/openapi/src/sdk/config.ts index fdfc58093..22653ddec 100644 --- a/packages/plugins/openapi/src/sdk/config.ts +++ b/packages/plugins/openapi/src/sdk/config.ts @@ -38,6 +38,7 @@ const OAuthAuthenticationSchema = Schema.Struct({ resource: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.Array(Schema.String), supportsClientIdMetadataDocument: Schema.optional(Schema.Boolean), + discoveryUrl: Schema.optional(Schema.String), }); export const AuthenticationSchema = Schema.Union([OAuthAuthenticationSchema, ApiKeyAuthMethod]); @@ -81,6 +82,16 @@ export type OpenApiIntegrationConfig = Omit< readonly specOverrides?: SpecOverrides; }; +/** Legacy templates need connect-time recovery from the original spec. */ +export const openApiOAuthDiscoveryUrl = ( + template: Extract, + config?: Pick, +): string | undefined => + template.discoveryUrl ?? + (template.supportsClientIdMetadataDocument || template.slug === "oauth-DiscoveredOAuth2" + ? (template.resource ?? config?.baseUrl ?? config?.specUrl) + : undefined); + const decodeConfig = Schema.decodeUnknownOption(OpenApiIntegrationConfigSchema); /** Decode the opaque integration config blob into the openapi shape. diff --git a/packages/plugins/openapi/src/sdk/derive-auth.ts b/packages/plugins/openapi/src/sdk/derive-auth.ts index 243cc7bab..3acedeb04 100644 --- a/packages/plugins/openapi/src/sdk/derive-auth.ts +++ b/packages/plugins/openapi/src/sdk/derive-auth.ts @@ -151,6 +151,7 @@ const oauthTemplateFromPreset = ( tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), resource: Option.getOrUndefined(preset.resource) ?? null, scopes: [...scopes], + ...(preset.discoveryUrl ? { discoveryUrl: preset.discoveryUrl } : {}), ...(preset.supportsClientIdMetadataDocument === true ? { supportsClientIdMetadataDocument: true } : {}), diff --git a/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts b/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts index 48755e521..9c9c0c4dc 100644 --- a/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/openapi/src/sdk/describe-auth-methods.test.ts @@ -29,6 +29,28 @@ const recordWith = (templates: readonly Authentication[]): IntegrationRecord => }); describe("describeOpenApiAuthMethods", () => { + it("uses the original base URL for issuer-only legacy discovery", () => { + const record = recordWith([]); + const methods = describeOpenApiAuthMethods({ + ...record, + config: { + authenticationTemplate: [ + { + kind: "oauth2", + slug: AuthTemplateSlug.make("oauth-DiscoveredOAuth2"), + authorizationUrl: "https://provider.example/tenant/oauth/authorize", + tokenUrl: "https://provider.example/tenant/oauth/token", + resource: null, + scopes: [], + supportsClientIdMetadataDocument: true, + }, + ], + baseUrl: "https://provider.example/tenant", + }, + }); + expect(methods[0]?.oauth?.discoveryUrl).toBe("https://provider.example/tenant"); + }); + it("projects an apiKey header template to an apikey method with the placement prefix", () => { const methods = describeOpenApiAuthMethods( recordWith([ @@ -77,6 +99,7 @@ describe("describeOpenApiAuthMethods", () => { authorizationUrl: "https://auth.example/authorize", tokenUrl: "https://auth.example/token", resource: "https://api.example", + discoveryUrl: "https://api.example", scopes: ["read", "write"], supportsClientIdMetadataDocument: true, }, diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 1e125e3b9..980b1b6a3 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -420,7 +420,7 @@ describe("OpenAPI Plugin", () => { expect(oauth.flow).toBe("authorizationCode"); expect(oauth.tokenUrl).toBe(`${server.baseUrl}/oauth/token/`); expect(Option.getOrNull(oauth.resource)).toBe(server.baseUrl); - expect(oauth.supportsClientIdMetadataDocument).toBe(true); + expect(oauth.discoveryUrl).toBe(server.baseUrl); expect(oauth.scopes).toEqual({ "project:read": "" }); yield* executor.openapi.addSpec({ @@ -436,8 +436,7 @@ describe("OpenAPI Plugin", () => { ...(template.kind === "oauth2" ? { resource: template.resource ?? null, - supportsClientIdMetadataDocument: - template.supportsClientIdMetadataDocument === true, + discoveryUrl: template.discoveryUrl, } : {}), })), @@ -447,13 +446,64 @@ describe("OpenAPI Plugin", () => { slug: "oauth-DiscoveredOAuth2", kind: "oauth2", resource: server.baseUrl, - supportsClientIdMetadataDocument: true, + discoveryUrl: `${server.baseUrl}/`, }, ]); }), ), ); + it.effect("getConfig preserves CIMD across edits while the deployment disables it", () => + Effect.scoped( + Effect.gen(function* () { + const { executor, config } = yield* makeTestWorkspaceHarness({ plugins: testPlugins() }); + const slug = IntegrationSlug.make("cimd_api"); + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: testApiSpecText() }, + slug, + authenticationTemplate: [ + apiKeyTemplate, + { ...oauthTemplate, supportsClientIdMetadataDocument: true }, + ], + }); + const enabledConfig = yield* executor.openapi.getConfig(slug); + const [, oauth] = enabledConfig?.authenticationTemplate ?? []; + expect(oauth).toMatchObject({ kind: "oauth2", supportsClientIdMetadataDocument: true }); + yield* executor.close(); + + const disabled = yield* Effect.acquireRelease( + createExecutor({ ...config, oauthClientIdMetadataDocumentEnabled: false }), + (instance) => instance.close().pipe(Effect.orDie), + ); + expect(yield* disabled.oauth.listClients()).toEqual([]); + expect((yield* disabled.integrations.get(slug))?.authMethods).toContainEqual( + expect.objectContaining({ + kind: "oauth", + oauth: expect.objectContaining({ supportsClientIdMetadataDocument: false }), + }), + ); + const editable = yield* disabled.openapi.getConfig(slug); + expect(editable).toEqual(enabledConfig); + yield* disabled.openapi.configure(slug, { + mode: "replace", + authenticationTemplate: editable?.authenticationTemplate?.filter( + (method) => method.kind === "oauth2", + ), + }); + yield* disabled.close(); + + const reenabled = yield* Effect.acquireRelease( + createExecutor({ ...config, oauthClientIdMetadataDocumentEnabled: true }), + (instance) => instance.close().pipe(Effect.orDie), + ); + expect(yield* reenabled.openapi.getConfig(slug)).toEqual({ + ...enabledConfig, + authenticationTemplate: [oauth], + }); + }), + ), + ); + it.effect("exposes static openapi executor control tools via execute", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index b41816c1c..ce04f5d6d 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -6,6 +6,7 @@ import { IntegrationAlreadyExistsError, IntegrationDetectionResult, IntegrationNotFoundError, + OAuthProbeError, IntegrationSlug, ToolResult, definePlugin, @@ -23,7 +24,11 @@ import { type StorageFailure, } from "@executor-js/sdk/core"; -import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; +import { + decodeOpenApiIntegrationConfig, + openApiOAuthDiscoveryUrl, + type OpenApiIntegrationConfig, +} from "./config"; import { OpenApiExtractionError, OpenApiOAuthError, @@ -69,6 +74,7 @@ import { } from "./backing"; import type { InvokeOptions } from "./invoke"; import { resolveServerUrl } from "./openapi-utils"; +import { parseHead, structuralSplit } from "./split"; import { applySpecOverrides, decodeOpenApiSpecOverrides, @@ -196,8 +202,8 @@ export interface OpenApiPluginExtension { >; readonly removeSpec: (slug: string) => Effect.Effect; readonly getIntegration: (slug: string) => Effect.Effect; - /** Read the integration's full opaque config, including its - * `authenticationTemplate`. Returns null when the integration is absent. */ + /** Read the stored integration config, including authentication templates. + * Returns null when the integration is absent. */ readonly getConfig: ( slug: string, ) => Effect.Effect; @@ -270,6 +276,7 @@ const StaticPreviewOAuth2PresetSchema = Schema.Struct({ Schema.Array(Schema.String), ]), supportsClientIdMetadataDocument: Schema.optional(Schema.Boolean), + discoveryUrl: Schema.optional(Schema.String), }); const StaticPreviewSpecOutputSchema = Schema.Struct({ title: Schema.NullOr(Schema.String), @@ -305,6 +312,7 @@ const AuthenticationSchema = Schema.Union([ resource: Schema.optional(Schema.NullOr(Schema.String)), scopes: Schema.Array(Schema.String), supportsClientIdMetadataDocument: Schema.optional(Schema.Boolean), + discoveryUrl: Schema.optional(Schema.String), }), // Credential methods are authored request-shaped - the ONE apikey input // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ", @@ -435,6 +443,7 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( scopes: preset.scopes, identityScopes: preset.identityScopes, supportsClientIdMetadataDocument: preset.supportsClientIdMetadataDocument, + discoveryUrl: preset.discoveryUrl, })), }); @@ -477,7 +486,7 @@ const addProbeCandidate = (candidates: string[], value: string | undefined): voi }; const oauthProbeCandidates = ( - preview: SpecPreview, + preview: Pick, specUrl: string | undefined, baseUrl: string | undefined, ): readonly string[] => { @@ -545,7 +554,7 @@ const discoveredOAuthPreview = (input: { readonly tokenUrl: string; readonly resource?: string | null; readonly scopes: readonly string[]; - readonly supportsClientIdMetadataDocument?: boolean; + readonly discoveryUrl: string; }): SpecPreview => { const scopes = Object.fromEntries(input.scopes.map((scope) => [scope, ""])); const flow = OAuth2AuthorizationCodeFlow.make({ @@ -586,9 +595,7 @@ const discoveredOAuthPreview = (input: { refreshUrl: Option.none(), scopes, identityScopes: "auto", - ...(input.supportsClientIdMetadataDocument === true - ? { supportsClientIdMetadataDocument: true } - : {}), + discoveryUrl: input.discoveryUrl, }), ], }; @@ -622,6 +629,7 @@ export const describeOpenApiAuthMethods = ( resource: template.resource ?? null, scopes: template.scopes, supportsClientIdMetadataDocument: template.supportsClientIdMetadataDocument, + discoveryUrl: openApiOAuthDiscoveryUrl(template, config), }, }; } @@ -797,8 +805,7 @@ export const openApiPlugin = definePlugin< tokenUrl: oauth.result.tokenUrl, resource: oauth.result.resource ?? null, scopes, - supportsClientIdMetadataDocument: - oauth.result.clientIdMetadataDocumentSupported === true, + discoveryUrl: candidate, }); } @@ -856,12 +863,14 @@ export const openApiPlugin = definePlugin< ? yield* previewSpecTextStreaming(resolved.specText, resolved.keepPathItem) : yield* previewSpecText(resolved.specText).pipe( Effect.flatMap((rawPreview) => - enrichPreviewWithDiscoveredOAuth({ - specText: resolved.specText, - preview: rawPreview, - specUrl: resolved.specUrl ?? specInputToSpecUrl(config.spec), - baseUrl: explicitBaseUrl, - }), + needsDerivedAuth + ? enrichPreviewWithDiscoveredOAuth({ + specText: resolved.specText, + preview: rawPreview, + specUrl: resolved.specUrl ?? specInputToSpecUrl(config.spec), + baseUrl: explicitBaseUrl, + }) + : Effect.succeed(rawPreview), ), ) : undefined; @@ -1370,6 +1379,87 @@ export const openApiPlugin = definePlugin< }, ], + recoverOAuthDiscovery: ({ ctx, integration, template: slug }) => + Effect.gen(function* () { + const config = decodeOpenApiIntegrationConfig(integration.config); + const template = config?.authenticationTemplate?.find((method) => method.slug === slug); + if (!config || template?.kind !== "oauth2") return null; + // Callers may still hold the pre-recovery catalog hint. Always use the + // saved URL, but re-probe it so deployment capability is never cached. + if (template.discoveryUrl) return yield* ctx.oauth.probe({ url: template.discoveryUrl }); + if ( + !(template.supportsClientIdMetadataDocument || template.slug === "oauth-DiscoveredOAuth2") + ) { + return null; + } + // Use the saved spec, not a newly fetched document which may have changed + // providers since this template was created. Discovery stays off catalog reads. + const specText = config.specHash ? yield* ctx.storage.getSpec(config.specHash) : null; + const structure = specText ? structuralSplit(specText) : null; + const preview = specText + ? yield* previewSpecText( + structure ? encodeJsonText({ ...parseHead(structure), paths: {} }) : specText, + ).pipe( + Effect.mapError( + () => + new OAuthProbeError({ + message: "Cannot read the saved OpenAPI spec for OAuth discovery", + }), + ), + ) + : null; + const candidates: string[] = []; + addProbeCandidate(candidates, template.resource ?? undefined); + for (const candidate of oauthProbeCandidates( + preview ?? { servers: [] }, + config.specUrl, + config.baseUrl, + )) { + if (!candidates.includes(candidate)) candidates.push(candidate); + } + for (const url of candidates) { + const probe = yield* ctx.oauth + .probe({ url }) + .pipe(Effect.catchTag("OAuthProbeError", () => Effect.succeed(null))); + // A spec may name multiple servers. Never register at an unrelated AS. + if ( + !probe || + probe.authorizationUrl !== template.authorizationUrl || + probe.tokenUrl !== template.tokenUrl + ) + continue; + yield* ctx + .transaction( + Effect.gen(function* () { + const record = yield* ctx.core.integrations.get(integration.slug); + const current = record ? decodeOpenApiIntegrationConfig(record.config) : null; + if (!current) return; + const methods = current.authenticationTemplate?.map((method) => + method.kind === "oauth2" && + method.slug === slug && + !method.discoveryUrl && + method.authorizationUrl === template.authorizationUrl && + method.tokenUrl === template.tokenUrl + ? { ...method, discoveryUrl: url } + : method, + ); + yield* ctx.core.integrations.update(integration.slug, { + config: { ...current, authenticationTemplate: methods } as IntegrationConfig, + }); + }), + ) + .pipe( + // Members can connect without permission to rewrite org configuration. + Effect.catchTag("OrgWriteDeniedError", () => Effect.void), + ); + return probe; + } + return yield* new OAuthProbeError({ + message: + "No OAuth metadata matching the stored OpenAPI authorization and token endpoints was found", + }); + }), + describeAuthMethods: describeOpenApiAuthMethods, describeIntegrationDisplay: describeOpenApiIntegrationDisplay, diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index 84506ad47..bfd5e7c8e 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -157,6 +157,8 @@ export const OAuth2Preset = Schema.Struct({ ]), /** Provider metadata advertised Client ID Metadata Document support. */ supportsClientIdMetadataDocument: Schema.optional(Schema.Boolean), + /** The candidate whose OAuth discovery succeeded; deployment policy is rechecked at connect. */ + discoveryUrl: Schema.optional(Schema.String), }); export type OAuth2Preset = typeof OAuth2Preset.Type; diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 5e10fd509..3a84d9d60 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -825,7 +825,7 @@ export async function runCimdConnect( // Automatic discovered OAuth connect orchestration. // // MCP OAuth is discovered at connect time. Prefer Client ID Metadata Documents -// when the authorization server advertises them; otherwise use Dynamic Client +// when `OAuthProbeResult` reports support; otherwise use Dynamic Client // Registration when available. Both paths keep the popup reserved by the // original click and avoid a provider-specific app picker. // @@ -956,7 +956,7 @@ const DCR_CLIENT_NAME = "Executor"; * * - Popup refused → `{ kind: "popup-blocked" }` before any network call. * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO). - * - CIMD advertised → create/reuse the public metadata client, then start. + * - Probe reports CIMD support -> create/reuse the public metadata client, then start. * - Otherwise no DCR endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`. * - Register rejected with a message → `{ kind: "fallback", reason: "registration-failed", probe, message }` * so the caller can show why (e.g. a redirect-URI rejection) over the generic copy. @@ -1751,10 +1751,10 @@ function AddAccountModalView(props: AddAccountModalProps) { method != null && method.placements.length > 0 && method.placements.every((p) => p.carrier === "env"); - // CIMD-capable: the provider accepts a client_id that is a metadata-document - // URL, so we can create a public local client and skip provider app - // registration entirely. - const isCimd = isOAuth && method?.oauth?.supportsClientIdMetadataDocument === true; + // Discovery-backed methods choose CIMD or DCR from a fresh server probe. + // Only static CIMD methods use the direct metadata-client path. + const isCimd = + isOAuth && method?.oauth?.supportsClientIdMetadataDocument === true && !hasDcr(method); const cimdActive = isCimd; // Single-input header/query methods: the placement's lead + prefix (e.g. // "Authorization: Bearer ") merges INTO the credential field as a non-editable @@ -2438,7 +2438,10 @@ function AddAccountModalView(props: AddAccountModalProps) { // must not register a client or launch the popup afterwards. isActive: () => viewMountedRef.current, probe: async (url: string): Promise => { - const exit = await doProbe({ payload: { url }, reactivityKeys: [] }); + const exit = await doProbe({ + payload: { url, integration, template: requestMethod.template }, + reactivityKeys: [], + }); if (Exit.isFailure(exit)) return null; return exit.value; }, @@ -2522,10 +2525,12 @@ function AddAccountModalView(props: AddAccountModalProps) { }, { discoveryUrl, - // Only a genuine discovery URL (MCP) seeds the RFC 8707 resource - // indicator; the token-endpoint fallback baked into `discoveryUrl` must - // not, so pass the un-collapsed method value here. - resourceFallback: requestMethod.oauth?.discoveryUrl, + // MCP's discovery URL identifies its protected resource. OpenAPI + // discovery may identify an issuer or token endpoint instead; use + // its declared resource rather than treating that URL as a resource. + resourceFallback: requestMethod.oauth?.tokenUrl + ? (requestMethod.oauth.resource ?? undefined) + : requestMethod.oauth?.discoveryUrl, owner: dcrOwner, // DCR slugs are server-keyed (Part A): the connect path no longer depends // on the picker's app list, so it need not be threaded here. diff --git a/packages/react/src/components/auth-template-editor.tsx b/packages/react/src/components/auth-template-editor.tsx index 02b2906c0..e05826fd1 100644 --- a/packages/react/src/components/auth-template-editor.tsx +++ b/packages/react/src/components/auth-template-editor.tsx @@ -39,6 +39,8 @@ export type AuthTemplateEditorValue = readonly resource?: string | null; readonly scopes: readonly string[]; readonly supportsClientIdMetadataDocument?: boolean; + /** Endpoint to re-probe before choosing CIMD or dynamic registration. */ + readonly discoveryUrl?: string; }; export interface AuthTemplateEditorPreset { diff --git a/packages/react/src/lib/auth-placements.tsx b/packages/react/src/lib/auth-placements.tsx index 9b9311850..14afa7f6e 100644 --- a/packages/react/src/lib/auth-placements.tsx +++ b/packages/react/src/lib/auth-placements.tsx @@ -85,10 +85,8 @@ export interface AuthMethodOAuth { * registration. Drives the transparent auto-register connect flow (probe → * register → start, with no app picker). */ readonly supportsDynamicRegistration?: boolean; - /** True when the authorization server supports OAuth Client ID Metadata - * Document. The connect flow can create a public local client whose - * `client_id` is this host's metadata document URL, with no provider-side app - * registration. */ + /** See the SDK's `AuthMethodOAuthDescriptor.supportsClientIdMetadataDocument` + * contract for catalog capability semantics. */ readonly supportsClientIdMetadataDocument?: boolean; }