Skip to content

Commit 2bdbedf

Browse files
authored
Reserve the OAuth sign-in window on the click, not after discovery (#1703)
The transparent DCR/CIMD connects and OAuth reconnect opened the popup only after their setup round trips answered. window.open needs transient user activation, which expires a few seconds after the click, so a slow API meant the browser refused the window and the connect ended silently. Claim the window on the click and navigate it when the authorization URL arrives; close it on the paths that end without signing in, and on cancel and unmount. Report a refused window instead of swallowing it, and render the sign-in error above the footer where the automatic flows can show it.
1 parent 9a38ab4 commit 2bdbedf

6 files changed

Lines changed: 593 additions & 51 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@executor-js/react": patch
3+
---
4+
5+
**A slow OAuth discovery no longer kills the connect with no popup and no error**
6+
7+
The transparent connect flows opened the sign-in window only after their setup round trips had answered: DCR after probe and dynamic registration, CIMD after minting the client, reconnect after starting the session. `window.open` needs transient user activation, which browsers expire a few seconds after the click, so once the API was slow enough the browser refused the window and the connect ended with nothing on screen but the button returning to "Connect". Every MCP integration takes that path.
8+
9+
The window is now claimed on the click itself and navigated when the authorization URL arrives, however long that takes, and it is closed again on the paths that end without signing in (failed probe, no registration endpoint, rejected registration, failed client mint) as well as on cancel and unmount. A window the browser does refuse is now reported instead of swallowed: the flows stop before their round trips, and the sign-in error renders above the dialog footer, where the automatic flows can actually show it, rather than inside a method tab panel they never render.
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Selfhost browser coverage for the transparent DCR connect when OAuth
2+
// discovery is slow, the shape behind the report: "When I hit connect, it loads
3+
// for a second, but then theres no user feedback and nothing happens."
4+
//
5+
// One click runs probe -> register -> start. The first two are network round
6+
// trips, and `window.open` needs transient user activation, which a real
7+
// browser expires a few seconds after the click. The shipped code opened the
8+
// sign-in window AFTER both round trips, so once the API was slow the browser
9+
// refused it and the connect died silently. The window is now claimed on the
10+
// click and navigated later, so discovery latency no longer decides whether the
11+
// user can connect.
12+
//
13+
// What this scenario guards: the whole slow path still works end to end, with
14+
// the reservation threaded from the click through registration to a window that
15+
// really lands on the discovered authorization server.
16+
//
17+
// What it CANNOT guard, and why: Playwright drives Chromium in automation mode,
18+
// which never enforces the activation rule for `window.open` (verified headed
19+
// and headless, and with `--disable-popup-blocking` removed via
20+
// ignoreDefaultArgs). So a browser here opens the window no matter how stale
21+
// the click is, and this scenario passes against the pre-fix ordering too. The
22+
// ordering itself is pinned by unit tests over `runDcrConnect` in
23+
// packages/react/src/components/add-account-modal.test.ts, which do fail when
24+
// the reservation moves back after the round trips. See LEARNINGS.md.
25+
import { randomBytes } from "node:crypto";
26+
27+
import { expect } from "@effect/vitest";
28+
import { Effect } from "effect";
29+
import { composePluginApi } from "@executor-js/api/server";
30+
import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
31+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
32+
import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
33+
import { IntegrationSlug } from "@executor-js/sdk/shared";
34+
import { OAuthTestServer } from "@executor-js/sdk/testing";
35+
36+
import { scenario } from "../src/scenario";
37+
import { Api, Browser, Target } from "../src/services";
38+
39+
const api = composePluginApi([mcpHttpPlugin()] as const);
40+
41+
// Comfortably past Chromium's ~5s transient user activation once both land on
42+
// the same click, and well under the step timeouts below.
43+
const STALL_MS = 3_500;
44+
45+
const isDiscoveryCall = (url: string): boolean =>
46+
url.includes("/api/oauth/probe") || url.includes("/api/oauth/clients/register-dynamic");
47+
48+
scenario(
49+
"MCP OAuth · a slow discovery round trip still opens the sign-in window",
50+
{ timeout: 240_000 },
51+
Effect.scoped(
52+
Effect.gen(function* () {
53+
const target = yield* Target;
54+
const browser = yield* Browser;
55+
const { client: makeApiClient } = yield* Api;
56+
const oauth = yield* OAuthTestServer;
57+
const server = yield* serveMcpServerWithOAuth(
58+
() => makeGreetingMcpServer({ name: "slow-connect-mcp" }),
59+
{ path: "/mcp" },
60+
);
61+
const identity = yield* target.newIdentity();
62+
const client = yield* makeApiClient(api, identity);
63+
const displayName = `Slow MCP ${randomBytes(3).toString("hex")}`;
64+
const slug = IntegrationSlug.make(deriveMcpNamespace({ name: displayName }));
65+
66+
yield* Effect.gen(function* () {
67+
yield* browser.session(identity, async ({ page, step }) => {
68+
await step("Add an OAuth-protected MCP integration", async () => {
69+
const addUrl = new URL("/integrations/add/mcp", target.baseUrl);
70+
addUrl.searchParams.set("url", server.endpoint);
71+
await page.goto(addUrl.toString(), { waitUntil: "networkidle" });
72+
await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 });
73+
await page.getByPlaceholder("e.g. Linear").fill(displayName);
74+
await page.getByRole("button", { name: "Add integration" }).click();
75+
await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
76+
await page.getByText("Connections").first().waitFor();
77+
});
78+
79+
await step("Make OAuth discovery slow, the way a degraded API is", async () => {
80+
// Delay the responses rather than the requests, so the app sees a
81+
// genuinely slow API and not a stalled network stack.
82+
await page.route(
83+
(url) => isDiscoveryCall(url.href),
84+
async (route) => {
85+
await new Promise((resolve) => setTimeout(resolve, STALL_MS));
86+
await route.continue();
87+
},
88+
);
89+
});
90+
91+
await step("Connect, and wait out the slow discovery", async () => {
92+
await page.getByRole("button", { name: "Add connection" }).first().click();
93+
await page.getByRole("heading", { name: /Add connection/ }).waitFor();
94+
await page.getByRole("tab", { name: "OAuth" }).waitFor();
95+
96+
const popupPromise = page.waitForEvent("popup", { timeout: 60_000 });
97+
await page.getByRole("button", { name: "Connect", exact: true }).click();
98+
99+
// In a real browser the two stalls outlast the click's user
100+
// activation, so this popup only exists because it was reserved on
101+
// the click. Automation-mode Chromium would open it either way;
102+
// the ordering is pinned by the unit tests named above.
103+
const popup = await popupPromise;
104+
await popup.waitForURL((url) => url.origin === new URL(oauth.issuerUrl).origin, {
105+
timeout: 60_000,
106+
});
107+
await popup.waitForLoadState("domcontentloaded", { timeout: 30_000 });
108+
expect(
109+
new URL(popup.url()).origin,
110+
"the reserved window reached the discovered authorization host",
111+
).toBe(new URL(oauth.authorizationEndpoint).origin);
112+
await popup.close();
113+
});
114+
});
115+
116+
const oauthRequests = yield* oauth.requests;
117+
expect(
118+
oauthRequests.some(
119+
(request) => request.method === "POST" && request.path === "/register",
120+
),
121+
"the slow connect still dynamically registered its OAuth client",
122+
).toBe(true);
123+
expect(
124+
oauthRequests.some(
125+
(request) => request.method === "GET" && request.path === "/authorize",
126+
),
127+
"the slow connect still reached the authorize endpoint",
128+
).toBe(true);
129+
}).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore)));
130+
}),
131+
).pipe(Effect.provide(OAuthTestServer.layer())),
132+
);

‎packages/react/src/components/accounts-section.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ function OwnerAccounts(props: {
279279
}
280280
const payload = oauthReconnectPayload(connection);
281281
if (payload === null) return;
282+
// Claim the sign-in window on the click: `oauth.start` below is a network
283+
// round trip, and the browser's user activation can expire before it
284+
// answers, which would leave Reconnect silently doing nothing.
285+
const reservation = oauthPopup.reserve();
286+
if (reservation.kind === "blocked") return;
282287
// `oauth.start` discriminates the grant: client_credentials mints inline
283288
// (`status: "connected"`, no authorization URL) while authorization_code
284289
// returns a redirect the popup must complete. The popup hook only handles
@@ -291,6 +296,7 @@ function OwnerAccounts(props: {
291296
reactivityKeys: connectionWriteKeys,
292297
});
293298
if (Exit.isFailure(startExit)) {
299+
oauthPopup.releaseReservation();
294300
toast.error(messageFromExit(startExit, "Failed to reconnect"));
295301
trackEvent("connection_reconnected", {
296302
integration_slug: String(connection.integration),
@@ -301,6 +307,7 @@ function OwnerAccounts(props: {
301307
}
302308
const started = startExit.value;
303309
if (started.status === "connected") {
310+
oauthPopup.releaseReservation();
304311
toast.success("Reconnected");
305312
trackEvent("connection_reconnected", {
306313
integration_slug: String(connection.integration),
@@ -311,6 +318,7 @@ function OwnerAccounts(props: {
311318
}
312319
void oauthPopup.openAuthorization({
313320
owner: payload.owner,
321+
reservation,
314322
run: () =>
315323
Promise.resolve({
316324
state: started.state,

0 commit comments

Comments
 (0)