diff --git a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts new file mode 100644 index 0000000000..94f9d5fa1c --- /dev/null +++ b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts @@ -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 }))), +); diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index c6a0d73ee4..f891360192 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -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 { @@ -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", diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts index b95a3b2aa4..7094deeaf5 100644 --- a/packages/react/src/components/add-account-modal.test.ts +++ b/packages/react/src/components/add-account-modal.test.ts @@ -5,6 +5,7 @@ import { OAuthClientSlug, ProviderItemId, ProviderKey, + type OAuthProbeResult, type Owner, } from "@executor-js/sdk/shared"; @@ -18,8 +19,8 @@ import { DEFAULT_CONNECTION_OWNER, mergeCustomMethods, oauthIdentityLabelFromHealth, + runAutomaticOAuthConnect, runCimdConnect, - runDcrConnect, typedIdentityLabel, uniqueConnectionName, } from "./add-account-modal"; @@ -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; @@ -88,6 +81,30 @@ const popupSpy = (reservation: OAuthPopupReservation = RESERVED) => { }; }; +type AutomaticOAuthDeps = Parameters[0]; +type AutomaticOAuthInput = Parameters[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, + input: Omit, +) => + runAutomaticOAuthConnect( + { + ...deps, + createCimdClient: (): Promise => 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; @@ -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 => { + 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 => { + calls.push("create-cimd"); + createArgs = args; + return Promise.resolve(args.slug); + }, + register: (): Promise => { + 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 => Promise.resolve({ @@ -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"]); }); @@ -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); }); diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index c2a7a5ac98..43f72ef5e6 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -14,6 +14,7 @@ import { type HealthCheckResult, type HealthCheckSpec, type OAuthClientSummary, + type OAuthProbeResult, type Owner, } from "@executor-js/sdk/shared"; import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; @@ -667,20 +668,20 @@ type CimdOutcome = | { readonly kind: "popup-blocked" } | { readonly kind: "failed"; readonly reason: "missing-endpoints" | "create-failed" }; -export async function runCimdConnect( - deps: RunCimdConnectDeps, - input: RunCimdConnectInput, -): Promise { +type ResolveCimdClientDeps = Pick; +type ResolveCimdClientInput = RunCimdConnectInput; +type ResolveCimdClientOutcome = + | { readonly kind: "ready"; readonly client: OAuthClientSlug; readonly reused: boolean } + | { readonly kind: "failed"; readonly reason: "missing-endpoints" | "create-failed" }; + +async function resolveCimdClient( + deps: ResolveCimdClientDeps, + input: ResolveCimdClientInput, +): Promise { if (input.authorizationUrl.trim().length === 0 || input.tokenUrl.trim().length === 0) { return { kind: "failed", reason: "missing-endpoints" }; } - // Claim the window while the click still counts. Minting the client is a - // round trip, and the browser stops honouring `window.open` once the - // activation from the click expires. - const reservation = deps.reserve(); - if (reservation.kind === "blocked") return { kind: "popup-blocked" }; - const resource = input.resource ?? null; const existing = input.existingClients.find( (client) => @@ -692,10 +693,7 @@ export async function runCimdConnect( client.clientId === input.clientIdMetadataDocumentUrl, ); - if (existing) { - deps.start({ client: existing.slug, owner: input.owner, reservation }); - return { kind: "started", client: existing.slug, reused: true }; - } + if (existing) return { kind: "ready", client: existing.slug, reused: true }; const slug = uniqueClientSlug( `${input.integrationName} CIMD`, @@ -711,21 +709,40 @@ export async function runCimdConnect( clientId: input.clientIdMetadataDocumentUrl, clientSecret: "", }); - if (created === null) { + if (created === null) return { kind: "failed", reason: "create-failed" }; + return { kind: "ready", client: created, reused: false }; +} + +export async function runCimdConnect( + deps: RunCimdConnectDeps, + input: RunCimdConnectInput, +): Promise { + if (input.authorizationUrl.trim().length === 0 || input.tokenUrl.trim().length === 0) { + return { kind: "failed", reason: "missing-endpoints" }; + } + + // Claim the window while the click still counts. Minting the client is a + // round trip, and the browser stops honouring `window.open` once the + // activation from the click expires. + const reservation = deps.reserve(); + if (reservation.kind === "blocked") return { kind: "popup-blocked" }; + + const resolved = await resolveCimdClient(deps, input); + if (resolved.kind === "failed") { deps.release(); - return { kind: "failed", reason: "create-failed" }; + return resolved; } - deps.start({ client: created, owner: input.owner, reservation }); - return { kind: "started", client: created, reused: false }; + deps.start({ client: resolved.client, owner: input.owner, reservation }); + return { kind: "started", client: resolved.client, reused: resolved.reused }; } // --------------------------------------------------------------------------- -// Transparent DCR (RFC 7591) connect orchestration. +// Automatic discovered OAuth connect orchestration. // -// For DCR-capable methods (MCP OAuth) the user clicks one "Connect" button and -// we do everything: probe the integration's discovery URL → register a client -// against the advertised registration endpoint → start -// the OAuth flow with the minted client. No app picker, no pasted client id. +// MCP OAuth is discovered at connect time. Prefer Client ID Metadata Documents +// when the authorization server advertises them; otherwise use Dynamic Client +// Registration when available. Both paths keep the popup reserved by the +// original click and avoid a provider-specific app picker. // // This is extracted as a pure-ish orchestrator (injected `probe`/`register`/ // `start`) so the SEQUENCE is unit-testable without React/atoms. The caller @@ -733,17 +750,6 @@ export async function runCimdConnect( // popup-start atoms. // --------------------------------------------------------------------------- -/** Discovery result from the probe step (subset of the `probeOAuth` response). */ -type DcrProbeResult = { - 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 DcrRegisterArgs = { readonly owner: Owner; readonly slug: OAuthClientSlug; @@ -765,7 +771,7 @@ type DcrStartArgs = { readonly reservation: OAuthPopupReservation; }; -/** Outcome of the DCR orchestration. `"started"` means the OAuth flow handed +/** Outcome of automatic discovered OAuth orchestration. `"started"` means the OAuth flow handed * off (the popup/inline start ran); `"popup-blocked"` means the browser refused * the sign-in window, which no app picker can fix, so the caller reports it * rather than falling back; `"fallback"` means we could not auto-set-up (a @@ -773,24 +779,29 @@ type DcrStartArgs = { * caller should fall back to the bring-your-own-app picker. A failed probe * carries no probe result; the other two reasons always carry the probe that * seeds the picker. */ -type DcrOutcome = - | { readonly kind: "started" } +type AutomaticOAuthOutcome = + | { readonly kind: "started"; readonly flow: "cimd" | "dcr" } | { readonly kind: "popup-blocked" } | { readonly kind: "fallback"; readonly reason: "probe-failed" } | { readonly kind: "fallback"; - readonly reason: "no-registration-endpoint" | "registration-failed"; - readonly probe: DcrProbeResult; + readonly reason: + | "no-registration-endpoint" + | "client-metadata-failed" + | "registration-failed"; + readonly probe: OAuthProbeResult; /** A specific, user-facing reason to surface instead of the generic * "register an app" copy (e.g. a server that rejects the DCR redirect * URI). Absent when the fallback carries no actionable detail. */ readonly message?: string; }; -type RunDcrConnectDeps = { +type RunAutomaticOAuthConnectDeps = { /** Probe the discovery URL → resolved endpoints + (maybe) a registration * endpoint. Resolves to null when the probe fails. */ - readonly probe: (url: string) => Promise; + readonly probe: (url: string) => Promise; + /** Create or reuse the local public client used by a CIMD flow. */ + readonly createCimdClient: ResolveCimdClientDeps["createClient"]; /** Register a DCR client → the minted client slug, `{ error }` with a * user-facing message when the server rejects registration, or null on an * unexplained failure. */ @@ -805,7 +816,7 @@ type RunDcrConnectDeps = { readonly release: () => void; }; -type RunDcrConnectInput = { +type RunAutomaticOAuthConnectInput = { readonly discoveryUrl: string; /** The integration's genuine protected-resource URL (the MCP discovery URL), * used as the RFC 8707 resource indicator when the server's PRM names no @@ -821,6 +832,12 @@ type RunDcrConnectInput = { readonly redirectUri?: string; /** Integration that requested this DCR client. */ readonly integration: IntegrationSlug; + /** Executor's public client metadata document and the local clients it may + * reuse when the probe advertises CIMD. */ + readonly cimd: Pick< + RunCimdConnectInput, + "integrationName" | "clientIdMetadataDocumentUrl" | "existingClients" + >; }; /** RFC 7591 `client_name` sent for every dynamic registration. Deliberately @@ -833,20 +850,21 @@ type RunDcrConnectInput = { const DCR_CLIENT_NAME = "Executor"; /** - * Run the transparent DCR connect sequence: reserve → probe → register → start. + * Run automatic discovered OAuth: reserve → probe → CIMD or DCR → start. * * - Popup refused → `{ kind: "popup-blocked" }` before any network call. * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO). - * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`. + * - CIMD advertised → 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. * - Register failed without detail (null) → `{ kind: "fallback", reason: "registration-failed", probe }`. - * - Success → registers, calls `start`, returns `{ kind: "started" }`. + * - Success → calls `start` and reports which automatic flow was used. */ -export async function runDcrConnect( - deps: RunDcrConnectDeps, - input: RunDcrConnectInput, -): Promise { +export async function runAutomaticOAuthConnect( + deps: RunAutomaticOAuthConnectDeps, + input: RunAutomaticOAuthConnectInput, +): Promise { // Claim the sign-in window FIRST, on the click that got us here. Probe and // register are two network round trips; the browser's user activation expires // well before they finish, so a window opened after them is refused and the @@ -859,6 +877,26 @@ export async function runDcrConnect( deps.release(); return { kind: "fallback", reason: "probe-failed" }; } + if (probe.clientIdMetadataDocumentSupported === true) { + const resolved = await resolveCimdClient( + { createClient: deps.createCimdClient }, + { + owner: input.owner, + integrationName: input.cimd.integrationName, + authorizationUrl: probe.authorizationUrl, + tokenUrl: probe.tokenUrl, + resource: probe.resource ?? input.resourceFallback ?? null, + clientIdMetadataDocumentUrl: input.cimd.clientIdMetadataDocumentUrl, + existingClients: input.cimd.existingClients, + }, + ); + if (resolved.kind === "failed") { + deps.release(); + return { kind: "fallback", reason: "client-metadata-failed", probe }; + } + deps.start({ client: resolved.client, owner: input.owner, reservation }); + return { kind: "started", flow: "cimd" }; + } const registrationEndpoint = probe.registrationEndpoint; if (!registrationEndpoint) { deps.release(); @@ -892,7 +930,7 @@ export async function runDcrConnect( return { kind: "fallback", reason: "registration-failed", probe, message: minted.error }; } deps.start({ client: minted, owner: input.owner, reservation }); - return { kind: "started" }; + return { kind: "started", flow: "dcr" }; } // --------------------------------------------------------------------------- @@ -1252,11 +1290,11 @@ function AddAccountModalView(props: AddAccountModalProps) { const [registeringOAuthClient, setRegisteringOAuthClient] = useState(false); const [ccBusy, setCcBusy] = useState(false); const [cimdBusy, setCimdBusy] = useState(false); - // Transparent DCR: busy while probing/registering/starting; `dcrFailed` flips - // the modal to the bring-your-own-app picker if auto setup is unavailable. + // Automatic discovery: busy while probing/setting up/starting; `dcrFailed` + // retains its historical name and flips to the bring-your-own-app recovery. const [dcrBusy, setDcrBusy] = useState(false); const [dcrFailed, setDcrFailed] = useState(false); - const [oauthFallbackProbe, setOAuthFallbackProbe] = useState(null); + const [oauthFallbackProbe, setOAuthFallbackProbe] = useState(null); // When transparent DCR is rejected with an actionable reason (e.g. the server // refuses our redirect URI), surface it as an inline error card on the // bring-your-own-app recovery view instead of a transient toast. @@ -2117,6 +2155,24 @@ function AddAccountModalView(props: AddAccountModalProps) { }); }; + const createCimdClient = async (args: CimdCreateClientArgs): Promise => { + const exit = await doCreateOAuthClient({ + payload: { + owner: args.owner, + slug: args.slug, + authorizationUrl: args.authorizationUrl, + tokenUrl: args.tokenUrl, + resource: args.resource ?? null, + grant: args.grant, + clientId: args.clientId, + clientSecret: args.clientSecret, + }, + reactivityKeys: oauthClientWriteKeys, + }); + if (Exit.isFailure(exit)) return null; + return exit.value.client; + }; + const handleCimdConnect = async () => { const authorizationUrl = method?.oauth?.authorizationUrl; const tokenUrl = method?.oauth?.tokenUrl; @@ -2132,23 +2188,7 @@ function AddAccountModalView(props: AddAccountModalProps) { { reserve: oauthPopup.reserve, release: oauthPopup.releaseReservation, - createClient: async (args: CimdCreateClientArgs): Promise => { - const exit = await doCreateOAuthClient({ - payload: { - owner: args.owner, - slug: args.slug, - authorizationUrl: args.authorizationUrl, - tokenUrl: args.tokenUrl, - resource: args.resource ?? null, - grant: args.grant, - clientId: args.clientId, - clientSecret: args.clientSecret, - }, - reactivityKeys: oauthClientWriteKeys, - }); - if (Exit.isFailure(exit)) return null; - return exit.value.client; - }, + createClient: createCimdClient, start: (args: CimdStartArgs): void => { void oauthPopup.start({ reservation: args.reservation, @@ -2194,11 +2234,10 @@ function AddAccountModalView(props: AddAccountModalProps) { } }; - // Transparent DCR connect: probe → register → start, no app picker. On any - // failure (probe error, no registration endpoint, or registration failure) we - // flip `dcrFailed` so the bring-your-own-app picker renders as the recovery - // path with name/owner kept. - const handleDcrConnect = async () => { + // Automatic discovered OAuth connect: probe once, then prefer CIMD or use DCR + // according to the authorization server's advertised metadata. On failure we + // flip `dcrFailed` so the bring-your-own-app picker remains the recovery path. + const handleAutomaticOAuthConnect = async () => { const discoveryUrl = method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl; if (!method || !discoveryUrl) { setDcrFailed(true); @@ -2208,15 +2247,16 @@ function AddAccountModalView(props: AddAccountModalProps) { const connectionName = previewConnectionName(label, dcrOwner); const identityLabel = typedIdentityLabel(label); setDcrBusy(true); - const outcome = await runDcrConnect( + const outcome = await runAutomaticOAuthConnect( { reserve: oauthPopup.reserve, release: oauthPopup.releaseReservation, - probe: async (url: string): Promise => { + probe: async (url: string): Promise => { const exit = await doProbe({ payload: { url }, reactivityKeys: [] }); if (Exit.isFailure(exit)) return null; return exit.value; }, + createCimdClient, register: async ( args: DcrRegisterArgs, ): Promise => { @@ -2279,13 +2319,23 @@ function AddAccountModalView(props: AddAccountModalProps) { declaredScopes: method.oauth?.scopes, redirectUri: oauthCallbackUrl(), integration, + cimd: { + integrationName, + clientIdMetadataDocumentUrl: oauthClientIdMetadataDocumentUrl(), + existingClients: clientSummaries, + }, }, ); setDcrBusy(false); trackEvent("connection_oauth_started", { integration_slug: String(integration), owner: dcrOwner, - flow: "dcr", + flow: + outcome.kind === "started" + ? outcome.flow + : "probe" in outcome && outcome.probe.clientIdMetadataDocumentSupported === true + ? "cimd" + : "dcr", success: outcome.kind === "started", ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}), }); @@ -2989,7 +3039,7 @@ function AddAccountModalView(props: AddAccountModalProps) { ) : dcrActive ? (