History

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.

Rhys Sullivan committed 2 months ago

Commit 2bdbedf1 parent 9a38ab46 files · +593 −51Browse files

Unified · Split

6 files
  1. .changeset/oauth-popup-reserve-on-click.md +9 −0
  2. e2e/selfhost/mcp-oauth-slow-connect.test.ts +132 −0
  3. packages/react/src/components/accounts-section.tsx +8 −0
  4. packages/react/src/components/add-account-modal.test.ts +275 −4
  5. packages/react/src/components/add-account-modal.tsx +79 −27
  6. packages/react/src/plugins/oauth-sign-in.tsx +90 −20
.changeset/oauth-popup-reserve-on-click.md+9−0
@@ -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
7The 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
9The 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.
e2e/selfhost/mcp-oauth-slow-connect.test.ts+132−0
@@ -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.
25import { randomBytes } from "node:crypto";
26
27import { expect } from "@effect/vitest";
28import { Effect } from "effect";
29import { composePluginApi } from "@executor-js/api/server";
30import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
31import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
32import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing";
33import { IntegrationSlug } from "@executor-js/sdk/shared";
34import { OAuthTestServer } from "@executor-js/sdk/testing";
35
36import { scenario } from "../src/scenario";
37import { Api, Browser, Target } from "../src/services";
38
39const 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.
43const STALL_MS = 3_500;
44
45const isDiscoveryCall = (url: string): boolean =>
46 url.includes("/api/oauth/probe") || url.includes("/api/oauth/clients/register-dynamic");
47
48scenario(
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+8−0
@@ -279,6 +279,11 @@ function OwnerAccounts(props: { · 278 unmodified lines
279 }279 }
280 const payload = oauthReconnectPayload(connection);280 const payload = oauthReconnectPayload(connection);
281 if (payload === null) return;281 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;
282 // `oauth.start` discriminates the grant: client_credentials mints inline287 // `oauth.start` discriminates the grant: client_credentials mints inline
283 // (`status: "connected"`, no authorization URL) while authorization_code288 // (`status: "connected"`, no authorization URL) while authorization_code
284 // returns a redirect the popup must complete. The popup hook only handles289 // returns a redirect the popup must complete. The popup hook only handles
@@ -291,6 +296,7 @@ function OwnerAccounts(props: { · 6 unmodified lines
291 reactivityKeys: connectionWriteKeys,296 reactivityKeys: connectionWriteKeys,
292 });297 });
293 if (Exit.isFailure(startExit)) {298 if (Exit.isFailure(startExit)) {
299 oauthPopup.releaseReservation();
294 toast.error(messageFromExit(startExit, "Failed to reconnect"));300 toast.error(messageFromExit(startExit, "Failed to reconnect"));
295 trackEvent("connection_reconnected", {301 trackEvent("connection_reconnected", {
296 integration_slug: String(connection.integration),302 integration_slug: String(connection.integration),
@@ -301,6 +307,7 @@ function OwnerAccounts(props: { · 4 unmodified lines
301 }307 }
302 const started = startExit.value;308 const started = startExit.value;
303 if (started.status === "connected") {309 if (started.status === "connected") {
310 oauthPopup.releaseReservation();
304 toast.success("Reconnected");311 toast.success("Reconnected");
305 trackEvent("connection_reconnected", {312 trackEvent("connection_reconnected", {
306 integration_slug: String(connection.integration),313 integration_slug: String(connection.integration),
@@ -311,6 +318,7 @@ function OwnerAccounts(props: { · 4 unmodified lines
311 }318 }
312 void oauthPopup.openAuthorization({319 void oauthPopup.openAuthorization({
313 owner: payload.owner,320 owner: payload.owner,
321 reservation,
314 run: () =>322 run: () =>
315 Promise.resolve({323 Promise.resolve({
316 state: started.state,324 state: started.state,
packages/react/src/components/add-account-modal.test.ts+275−4
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9} from "@executor-js/sdk/shared";9} from "@executor-js/sdk/shared";
10 10
11import type { AuthMethod } from "../lib/auth-placements";11import type { AuthMethod } from "../lib/auth-placements";
12import type { OAuthPopupReservation } from "../plugins/oauth-sign-in";
12import {13import {
13 connectionNameFrom,14 connectionNameFrom,
14 connectionLabel,15 connectionLabel,
@@ -57,7 +58,35 @@ type RegisterArgs = { · 42 unmodified lines
57 readonly originIntegration?: IntegrationSlug;58 readonly originIntegration?: IntegrationSlug;
58};59};
59 60
60type StartArgs = { readonly client: OAuthClientSlug; readonly owner: Owner };61type StartArgs = {
62 readonly client: OAuthClientSlug;
63 readonly owner: Owner;
64 readonly reservation: OAuthPopupReservation;
65};
66
67// A reservation the browser honoured. The desktop shape is the one that carries
68// no `Window`, so the orchestrators' ordering is testable without a DOM.
69const RESERVED: OAuthPopupReservation = {
70 kind: "desktop",
71 bridge: { openExternal: (): Promise<void> => Promise.resolve() },
72};
73
74/** Records the reserve/release calls alongside the network steps, so a test can
75 * assert the window is claimed BEFORE the first round trip and closed again
76 * whenever the flow ends without signing in. */
77const popupSpy = (reservation: OAuthPopupReservation = RESERVED) => {
78 const calls: string[] = [];
79 return {
80 calls,
81 reserve: (): OAuthPopupReservation => {
82 calls.push("reserve");
83 return reservation;
84 },
85 release: (): void => {
86 calls.push("release");
87 },
88 };
89};
61 90
62type CimdCreateArgs = {91type CimdCreateArgs = {
63 readonly owner: Owner;92 readonly owner: Owner;
@@ -70,7 +99,11 @@ type CimdCreateArgs = { · 6 unmodified lines
70 readonly clientSecret: "";99 readonly clientSecret: "";
71};100};
72 101
73type CimdStartArgs = { readonly client: OAuthClientSlug; readonly owner: Owner };102type CimdStartArgs = {
103 readonly client: OAuthClientSlug;
104 readonly owner: Owner;
105 readonly reservation: OAuthPopupReservation;
106};
74 107
75const TEST_INTEGRATION = IntegrationSlug.make("linear_mcp");108const TEST_INTEGRATION = IntegrationSlug.make("linear_mcp");
76 109
@@ -310,6 +343,8 @@ describe("runCimdConnect", () => { · 233 unmodified lines
310 343
311 const outcome = await runCimdConnect(344 const outcome = await runCimdConnect(
312 {345 {
346 reserve: (): OAuthPopupReservation => RESERVED,
347 release: (): void => {},
313 createClient: (args: CimdCreateArgs): Promise<OAuthClientSlug | null> => {348 createClient: (args: CimdCreateArgs): Promise<OAuthClientSlug | null> => {
314 createArgs = args;349 createArgs = args;
315 return Promise.resolve(args.slug);350 return Promise.resolve(args.slug);
@@ -352,6 +387,8 @@ describe("runCimdConnect", () => { · 36 unmodified lines
352 387
353 const outcome = await runCimdConnect(388 const outcome = await runCimdConnect(
354 {389 {
390 reserve: (): OAuthPopupReservation => RESERVED,
391 release: (): void => {},
355 createClient: (): Promise<OAuthClientSlug | null> => {392 createClient: (): Promise<OAuthClientSlug | null> => {
356 created = true;393 created = true;
357 return Promise.resolve(OAuthClientSlug.make("new-client"));394 return Promise.resolve(OAuthClientSlug.make("new-client"));
@@ -383,7 +420,221 @@ describe("runCimdConnect", () => { · 25 unmodified lines
383 420
384 expect(outcome).toEqual({ kind: "started", client: existingSlug, reused: true });421 expect(outcome).toEqual({ kind: "started", client: existingSlug, reused: true });
385 expect(created).toBe(false);422 expect(created).toBe(false);
386 expect(startArgs).toEqual({ client: existingSlug, owner: "user" });423 expect(startArgs).toEqual({ client: existingSlug, owner: "user", reservation: RESERVED });
424 });
425});
426
427describe("runDcrConnect popup reservation", () => {
428 const probeOk = (): Promise<ProbeResult> =>
429 Promise.resolve({
430 authorizationUrl: "https://auth.example.com/authorize",
431 tokenUrl: "https://auth.example.com/token",
432 registrationEndpoint: "https://auth.example.com/register",
433 });
434 const dcrInput = {
435 discoveryUrl: "https://mcp.example.com/mcp",
436 owner: "user" as Owner,
437 integration: TEST_INTEGRATION,
438 };
439
440 // The regression: probe and register are two round trips, and a browser stops
441 // honouring `window.open` about five seconds after the click. Opening the
442 // window after them is refused, so the connect ends with no popup, no error,
443 // and the button back on "Connect". The window has to be claimed up front.
444 it("claims the sign-in window BEFORE the first network call", async () => {
445 const popup = popupSpy();
446 const outcome = await runDcrConnect(
447 {
448 ...popup,
449 probe: (): Promise<ProbeResult> => {
450 popup.calls.push("probe");
451 return probeOk();
452 },
453 register: (): Promise<OAuthClientSlug> => {
454 popup.calls.push("register");
455 return Promise.resolve(OAuthClientSlug.make("mcp-app"));
456 },
457 start: (): void => {
458 popup.calls.push("start");
459 },
460 },
461 dcrInput,
462 );
463
464 expect(outcome).toEqual({ kind: "started" });
465 expect(popup.calls).toEqual(["reserve", "probe", "register", "start"]);
466 });
467
468 it("hands the reserved window to start, so the flow navigates it rather than opening a new one", async () => {
469 let startArgs: StartArgs | null = null;
470 const outcome = await runDcrConnect(
471 {
472 ...popupSpy(),
473 probe: probeOk,
474 register: (): Promise<OAuthClientSlug> => Promise.resolve(OAuthClientSlug.make("mcp-app")),
475 start: (args: StartArgs): void => {
476 startArgs = args;
477 },
478 },
479 dcrInput,
480 );
481
482 expect(outcome).toEqual({ kind: "started" });
483 expect(startArgs!.reservation).toBe(RESERVED);
484 });
485
486 it("gives up before any network call when the browser refuses the window", async () => {
487 const popup = popupSpy({ kind: "blocked" });
488 let probed = false;
489 const outcome = await runDcrConnect(
490 {
491 ...popup,
492 probe: (): Promise<ProbeResult> => {
493 probed = true;
494 return probeOk();
495 },
496 register: (): Promise<OAuthClientSlug> => Promise.resolve(OAuthClientSlug.make("mcp-app")),
497 start: (): void => {},
498 },
499 dcrInput,
500 );
501
502 // No BYO fallback: registering an app by hand cannot open a window either.
503 expect(outcome).toEqual({ kind: "popup-blocked" });
504 expect(probed).toBe(false);
505 expect(popup.calls).toEqual(["reserve"]);
506 });
507
508 // Reserving early means a flow that ends without signing in is holding an
509 // about:blank window the user never asked for.
510 it("closes the reserved window when the probe fails", async () => {
511 const popup = popupSpy();
512 const outcome = await runDcrConnect(
513 {
514 ...popup,
515 probe: (): Promise<ProbeResult | null> => Promise.resolve(null),
516 register: (): Promise<OAuthClientSlug> => Promise.resolve(OAuthClientSlug.make("mcp-app")),
517 start: (): void => {},
518 },
519 dcrInput,
520 );
521
522 expect(outcome).toEqual({ kind: "fallback", reason: "probe-failed" });
523 expect(popup.calls).toEqual(["reserve", "release"]);
524 });
525
526 it("closes the reserved window when the server advertises no registration endpoint", async () => {
527 const popup = popupSpy();
528 const outcome = await runDcrConnect(
529 {
530 ...popup,
531 probe: (): Promise<ProbeResult> =>
532 Promise.resolve({
533 authorizationUrl: "https://auth.example.com/authorize",
534 tokenUrl: "https://auth.example.com/token",
535 }),
536 register: (): Promise<OAuthClientSlug> => Promise.resolve(OAuthClientSlug.make("mcp-app")),
537 start: (): void => {},
538 },
539 dcrInput,
540 );
541
542 expect(outcome.kind).toBe("fallback");
543 expect(popup.calls).toEqual(["reserve", "release"]);
544 });
545
546 it("closes the reserved window when registration is rejected", async () => {
547 const popup = popupSpy();
548 const outcome = await runDcrConnect(
549 {
550 ...popup,
551 probe: probeOk,
552 register: (): Promise<{ readonly error: string }> =>
553 Promise.resolve({ error: "redirect_uri not allowed" }),
554 start: (): void => {},
555 },
556 dcrInput,
557 );
558
559 expect(outcome).toMatchObject({ reason: "registration-failed" });
560 expect(popup.calls).toEqual(["reserve", "release"]);
561 });
562
563 it("keeps the reserved window open on the path that signs in", async () => {
564 const popup = popupSpy();
565 await runDcrConnect(
566 {
567 ...popup,
568 probe: probeOk,
569 register: (): Promise<OAuthClientSlug> => Promise.resolve(OAuthClientSlug.make("mcp-app")),
570 start: (): void => {},
571 },
572 dcrInput,
573 );
574
575 expect(popup.calls).not.toContain("release");
576 });
577});
578
579describe("runCimdConnect popup reservation", () => {
580 const cimdInput = {
581 owner: "user" as Owner,
582 integrationName: "PostHog API",
583 authorizationUrl: "https://us.posthog.com/oauth/authorize/",
584 tokenUrl: "https://us.posthog.com/oauth/token/",
585 resource: null,
586 clientIdMetadataDocumentUrl: "https://executor.example/api/oauth/client-id-metadata.json",
587 existingClients: [],
588 };
589
590 it("claims the sign-in window before minting the client", async () => {
591 const popup = popupSpy();
592 const outcome = await runCimdConnect(
593 {
594 ...popup,
595 createClient: (args: CimdCreateArgs): Promise<OAuthClientSlug> => {
596 popup.calls.push("createClient");
597 return Promise.resolve(args.slug);
598 },
599 start: (): void => {
600 popup.calls.push("start");
601 },
602 },
603 cimdInput,
604 );
605
606 expect(outcome.kind).toBe("started");
607 expect(popup.calls).toEqual(["reserve", "createClient", "start"]);
608 });
609
610 it("closes the reserved window when minting the client fails", async () => {
611 const popup = popupSpy();
612 const outcome = await runCimdConnect(
613 {
614 ...popup,
615 createClient: (): Promise<OAuthClientSlug | null> => Promise.resolve(null),
616 start: (): void => {},
617 },
618 cimdInput,
619 );
620
621 expect(outcome).toEqual({ kind: "failed", reason: "create-failed" });
622 expect(popup.calls).toEqual(["reserve", "release"]);
623 });
624
625 it("never claims a window when the method is missing its endpoints", async () => {
626 const popup = popupSpy();
627 const outcome = await runCimdConnect(
628 {
629 ...popup,
630 createClient: (): Promise<OAuthClientSlug | null> => Promise.resolve(null),
631 start: (): void => {},
632 },
633 { ...cimdInput, tokenUrl: " " },
634 );
635
636 expect(outcome).toEqual({ kind: "failed", reason: "missing-endpoints" });
637 expect(popup.calls).toEqual([]);
387 });638 });
388});639});
389 640
@@ -416,7 +667,13 @@ describe("runDcrConnect", () => { · 26 unmodified lines
416 };667 };
417 668
418 const outcome = await runDcrConnect(669 const outcome = await runDcrConnect(
419 { probe, register, start },670 {
671 reserve: (): OAuthPopupReservation => RESERVED,
672 release: (): void => {},
673 probe,
674 register,
675 start,
676 },
420 {677 {
421 discoveryUrl: "https://mcp.example.com/mcp",678 discoveryUrl: "https://mcp.example.com/mcp",
422 owner: "user",679 owner: "user",
@@ -454,6 +711,8 @@ describe("runDcrConnect", () => { · 31 unmodified lines
454 let registerArgs: RegisterArgs | null = null;711 let registerArgs: RegisterArgs | null = null;
455 const outcome = await runDcrConnect(712 const outcome = await runDcrConnect(
456 {713 {
714 reserve: (): OAuthPopupReservation => RESERVED,
715 release: (): void => {},
457 probe: (): Promise<ProbeResult | null> =>716 probe: (): Promise<ProbeResult | null> =>
458 Promise.resolve({717 Promise.resolve({
459 authorizationUrl: "https://auth.example.com/authorize",718 authorizationUrl: "https://auth.example.com/authorize",
@@ -490,6 +749,8 @@ describe("runDcrConnect", () => { · 30 unmodified lines
490 let registerArgs: RegisterArgs | null = null;749 let registerArgs: RegisterArgs | null = null;
491 const outcome = await runDcrConnect(750 const outcome = await runDcrConnect(
492 {751 {
752 reserve: (): OAuthPopupReservation => RESERVED,
753 release: (): void => {},
493 probe: (): Promise<ProbeResult | null> =>754 probe: (): Promise<ProbeResult | null> =>
494 Promise.resolve({755 Promise.resolve({
495 authorizationUrl: "https://auth.example.com/authorize",756 authorizationUrl: "https://auth.example.com/authorize",
@@ -517,6 +778,8 @@ describe("runDcrConnect", () => { · 21 unmodified lines
517 const calls: string[] = [];778 const calls: string[] = [];
518 const outcome = await runDcrConnect(779 const outcome = await runDcrConnect(
519 {780 {
781 reserve: (): OAuthPopupReservation => RESERVED,
782 release: (): void => {},
520 probe: (): Promise<ProbeResult | null> => {783 probe: (): Promise<ProbeResult | null> => {
521 calls.push("probe");784 calls.push("probe");
522 return Promise.resolve({785 return Promise.resolve({
@@ -554,6 +817,8 @@ describe("runDcrConnect", () => { · 31 unmodified lines
554 const calls: string[] = [];817 const calls: string[] = [];
555 const outcome = await runDcrConnect(818 const outcome = await runDcrConnect(
556 {819 {
820 reserve: (): OAuthPopupReservation => RESERVED,
821 release: (): void => {},
557 probe: (): Promise<ProbeResult | null> => {822 probe: (): Promise<ProbeResult | null> => {
558 calls.push("probe");823 calls.push("probe");
559 return Promise.resolve(null);824 return Promise.resolve(null);
@@ -580,6 +845,8 @@ describe("runDcrConnect", () => { · 20 unmodified lines
580 const calls: string[] = [];845 const calls: string[] = [];
581 const outcome = await runDcrConnect(846 const outcome = await runDcrConnect(
582 {847 {
848 reserve: (): OAuthPopupReservation => RESERVED,
849 release: (): void => {},
583 probe: (): Promise<ProbeResult | null> =>850 probe: (): Promise<ProbeResult | null> =>
584 Promise.resolve({851 Promise.resolve({
585 authorizationUrl: "https://auth.example.com/authorize",852 authorizationUrl: "https://auth.example.com/authorize",
@@ -620,6 +887,8 @@ describe("runDcrConnect", () => { · 34 unmodified lines
620 let started = false;887 let started = false;
621 const outcome = await runDcrConnect(888 const outcome = await runDcrConnect(
622 {889 {
890 reserve: (): OAuthPopupReservation => RESERVED,
891 release: (): void => {},
623 probe: (): Promise<ProbeResult | null> =>892 probe: (): Promise<ProbeResult | null> =>
624 Promise.resolve({893 Promise.resolve({
625 authorizationUrl: "https://auth.example.com/authorize",894 authorizationUrl: "https://auth.example.com/authorize",
@@ -659,6 +928,8 @@ describe("runDcrConnect", () => { · 33 unmodified lines
659 let registerArgs: RegisterArgs | null = null;928 let registerArgs: RegisterArgs | null = null;
660 const outcome = await runDcrConnect(929 const outcome = await runDcrConnect(
661 {930 {
931 reserve: (): OAuthPopupReservation => RESERVED,
932 release: (): void => {},
662 probe: (): Promise<ProbeResult | null> =>933 probe: (): Promise<ProbeResult | null> =>
663 Promise.resolve({934 Promise.resolve({
664 issuer: "https://auth.example.com",935 issuer: "https://auth.example.com",
packages/react/src/components/add-account-modal.tsx+79−27
@@ -62,6 +62,7 @@ import { · 61 unmodified lines
62 oauthClientIdMetadataDocumentUrl,62 oauthClientIdMetadataDocumentUrl,
63 useOAuthPopupFlow,63 useOAuthPopupFlow,
64 type OAuthCompletionPayload,64 type OAuthCompletionPayload,
65 type OAuthPopupReservation,
65} from "../plugins/oauth-sign-in";66} from "../plugins/oauth-sign-in";
66import {67import {
67 clientDisplayName,68 clientDisplayName,
@@ -638,7 +639,17 @@ type CimdCreateClientArgs = { · 570 unmodified lines
638 639
639type RunCimdConnectDeps = {640type RunCimdConnectDeps = {
640 readonly createClient: (args: CimdCreateClientArgs) => Promise<OAuthClientSlug | null>;641 readonly createClient: (args: CimdCreateClientArgs) => Promise<OAuthClientSlug | null>;
641 readonly start: (args: { readonly client: OAuthClientSlug; readonly owner: Owner }) => void;642 readonly start: (args: CimdStartArgs) => void;
643 /** Claim the sign-in window before any await. See `useOAuthPopupFlow.reserve`. */
644 readonly reserve: () => OAuthPopupReservation;
645 /** Close a claimed window this flow turned out not to need. */
646 readonly release: () => void;
647};
648
649type CimdStartArgs = {
650 readonly client: OAuthClientSlug;
651 readonly owner: Owner;
652 readonly reservation: OAuthPopupReservation;
642};653};
643 654
644type RunCimdConnectInput = {655type RunCimdConnectInput = {
@@ -653,6 +664,7 @@ type RunCimdConnectInput = { · 8 unmodified lines
653 664
654type CimdOutcome =665type CimdOutcome =
655 | { readonly kind: "started"; readonly client: OAuthClientSlug; readonly reused: boolean }666 | { readonly kind: "started"; readonly client: OAuthClientSlug; readonly reused: boolean }
667 | { readonly kind: "popup-blocked" }
656 | { readonly kind: "failed"; readonly reason: "missing-endpoints" | "create-failed" };668 | { readonly kind: "failed"; readonly reason: "missing-endpoints" | "create-failed" };
657 669
658export async function runCimdConnect(670export async function runCimdConnect(
@@ -663,6 +675,12 @@ export async function runCimdConnect( · 4 unmodified lines
663 return { kind: "failed", reason: "missing-endpoints" };675 return { kind: "failed", reason: "missing-endpoints" };
664 }676 }
665 677
678 // Claim the window while the click still counts. Minting the client is a
679 // round trip, and the browser stops honouring `window.open` once the
680 // activation from the click expires.
681 const reservation = deps.reserve();
682 if (reservation.kind === "blocked") return { kind: "popup-blocked" };
683
666 const resource = input.resource ?? null;684 const resource = input.resource ?? null;
667 const existing = input.existingClients.find(685 const existing = input.existingClients.find(
668 (client) =>686 (client) =>
@@ -675,7 +693,7 @@ export async function runCimdConnect( · 6 unmodified lines
675 );693 );
676 694
677 if (existing) {695 if (existing) {
678 deps.start({ client: existing.slug, owner: input.owner });696 deps.start({ client: existing.slug, owner: input.owner, reservation });
679 return { kind: "started", client: existing.slug, reused: true };697 return { kind: "started", client: existing.slug, reused: true };
680 }698 }
681 699
@@ -693,8 +711,11 @@ export async function runCimdConnect( · 11 unmodified lines
693 clientId: input.clientIdMetadataDocumentUrl,711 clientId: input.clientIdMetadataDocumentUrl,
694 clientSecret: "",712 clientSecret: "",
695 });713 });
696 if (created === null) return { kind: "failed", reason: "create-failed" };714 if (created === null) {
697 deps.start({ client: created, owner: input.owner });715 deps.release();
716 return { kind: "failed", reason: "create-failed" };
717 }
718 deps.start({ client: created, owner: input.owner, reservation });
698 return { kind: "started", client: created, reused: false };719 return { kind: "started", client: created, reused: false };
699}720}
700 721
@@ -741,16 +762,20 @@ type DcrRegisterArgs = { · 40 unmodified lines
741type DcrStartArgs = {762type DcrStartArgs = {
742 readonly client: OAuthClientSlug;763 readonly client: OAuthClientSlug;
743 readonly owner: Owner;764 readonly owner: Owner;
765 readonly reservation: OAuthPopupReservation;
744};766};
745 767
746/** Outcome of the DCR orchestration. `"started"` means the OAuth flow handed768/** Outcome of the DCR orchestration. `"started"` means the OAuth flow handed
747 * off (the popup/inline start ran); `"fallback"` means we could not auto-set-up769 * off (the popup/inline start ran); `"popup-blocked"` means the browser refused
748 * — a failed probe, no registration endpoint, or a failed registration — and770 * the sign-in window, which no app picker can fix, so the caller reports it
749 * the caller should fall back to the bring-your-own-app picker. A failed probe771 * rather than falling back; `"fallback"` means we could not auto-set-up (a
772 * failed probe, no registration endpoint, or a failed registration) and the
773 * caller should fall back to the bring-your-own-app picker. A failed probe
750 * carries no probe result; the other two reasons always carry the probe that774 * carries no probe result; the other two reasons always carry the probe that
751 * seeds the picker. */775 * seeds the picker. */
752type DcrOutcome =776type DcrOutcome =
753 | { readonly kind: "started" }777 | { readonly kind: "started" }
778 | { readonly kind: "popup-blocked" }
754 | { readonly kind: "fallback"; readonly reason: "probe-failed" }779 | { readonly kind: "fallback"; readonly reason: "probe-failed" }
755 | {780 | {
756 readonly kind: "fallback";781 readonly kind: "fallback";
@@ -774,6 +799,10 @@ type RunDcrConnectDeps = { · 17 unmodified lines
774 ) => Promise<OAuthClientSlug | { readonly error: string } | null>;799 ) => Promise<OAuthClientSlug | { readonly error: string } | null>;
775 /** Start the OAuth flow with the minted client (popup / inline). */800 /** Start the OAuth flow with the minted client (popup / inline). */
776 readonly start: (args: DcrStartArgs) => void;801 readonly start: (args: DcrStartArgs) => void;
802 /** Claim the sign-in window before any await. See `useOAuthPopupFlow.reserve`. */
803 readonly reserve: () => OAuthPopupReservation;
804 /** Close a claimed window this flow turned out not to need. */
805 readonly release: () => void;
777};806};
778 807
779type RunDcrConnectInput = {808type RunDcrConnectInput = {
@@ -804,8 +833,9 @@ type RunDcrConnectInput = { · 24 unmodified lines
804const DCR_CLIENT_NAME = "Executor";833const DCR_CLIENT_NAME = "Executor";
805 834
806/**835/**
807 * Run the transparent DCR connect sequence: probe → register → start.836 * Run the transparent DCR connect sequence: reserve → probe → register → start.
808 *837 *
838 * - Popup refused → `{ kind: "popup-blocked" }` before any network call.
809 * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO).839 * - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO).
810 * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`.840 * - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint", probe }`.
811 * - Register rejected with a message → `{ kind: "fallback", reason: "registration-failed", probe, message }`841 * - Register rejected with a message → `{ kind: "fallback", reason: "registration-failed", probe, message }`
@@ -817,10 +847,23 @@ export async function runDcrConnect( · 5 unmodified lines
817 deps: RunDcrConnectDeps,847 deps: RunDcrConnectDeps,
818 input: RunDcrConnectInput,848 input: RunDcrConnectInput,
819): Promise<DcrOutcome> {849): Promise<DcrOutcome> {
850 // Claim the sign-in window FIRST, on the click that got us here. Probe and
851 // register are two network round trips; the browser's user activation expires
852 // well before they finish, so a window opened after them is refused and the
853 // connect dies with nothing on screen.
854 const reservation = deps.reserve();
855 if (reservation.kind === "blocked") return { kind: "popup-blocked" };
856
820 const probe = await deps.probe(input.discoveryUrl);857 const probe = await deps.probe(input.discoveryUrl);
821 if (probe === null) return { kind: "fallback", reason: "probe-failed" };858 if (probe === null) {
859 deps.release();
860 return { kind: "fallback", reason: "probe-failed" };
861 }
822 const registrationEndpoint = probe.registrationEndpoint;862 const registrationEndpoint = probe.registrationEndpoint;
823 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };863 if (!registrationEndpoint) {
864 deps.release();
865 return { kind: "fallback", reason: "no-registration-endpoint", probe };
866 }
824 867
825 const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint);868 const slug = optimisticDcrClientSlug(probe.issuer ?? registrationEndpoint);
826 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);869 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);
@@ -838,13 +881,17 @@ export async function runDcrConnect( · 11 unmodified lines
838 redirectUri: input.redirectUri,881 redirectUri: input.redirectUri,
839 originIntegration: input.integration,882 originIntegration: input.integration,
840 });883 });
841 if (minted === null) return { kind: "fallback", reason: "registration-failed", probe };884 if (minted === null) {
885 deps.release();
886 return { kind: "fallback", reason: "registration-failed", probe };
887 }
842 // OAuthClientSlug is a branded string; an object return carries the failure888 // OAuthClientSlug is a branded string; an object return carries the failure
843 // message (e.g. the server rejected the redirect URI) for the BYO fallback.889 // message (e.g. the server rejected the redirect URI) for the BYO fallback.
844 if (typeof minted === "object") {890 if (typeof minted === "object") {
891 deps.release();
845 return { kind: "fallback", reason: "registration-failed", probe, message: minted.error };892 return { kind: "fallback", reason: "registration-failed", probe, message: minted.error };
846 }893 }
847 deps.start({ client: minted, owner: input.owner });894 deps.start({ client: minted, owner: input.owner, reservation });
848 return { kind: "started" };895 return { kind: "started" };
849}896}
850 897
@@ -2083,6 +2130,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1232 unmodified lines
2083 setCimdBusy(true);2130 setCimdBusy(true);
2084 const outcome = await runCimdConnect(2131 const outcome = await runCimdConnect(
2085 {2132 {
2133 reserve: oauthPopup.reserve,
2134 release: oauthPopup.releaseReservation,
2086 createClient: async (args: CimdCreateClientArgs): Promise<OAuthClientSlug | null> => {2135 createClient: async (args: CimdCreateClientArgs): Promise<OAuthClientSlug | null> => {
2087 const exit = await doCreateOAuthClient({2136 const exit = await doCreateOAuthClient({
2088 payload: {2137 payload: {
@@ -2100,8 +2149,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 11 unmodified lines
2100 if (Exit.isFailure(exit)) return null;2149 if (Exit.isFailure(exit)) return null;
2101 return exit.value.client;2150 return exit.value.client;
2102 },2151 },
2103 start: (args: { readonly client: OAuthClientSlug; readonly owner: Owner }): void => {2152 start: (args: CimdStartArgs): void => {
2104 void oauthPopup.start({2153 void oauthPopup.start({
2154 reservation: args.reservation,
2105 payload: {2155 payload: {
2106 client: args.client,2156 client: args.client,
2107 // CIMD creates the public client under the connection owner, so2157 // CIMD creates the public client under the connection owner, so
@@ -2160,6 +2210,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 52 unmodified lines
2160 setDcrBusy(true);2210 setDcrBusy(true);
2161 const outcome = await runDcrConnect(2211 const outcome = await runDcrConnect(
2162 {2212 {
2213 reserve: oauthPopup.reserve,
2214 release: oauthPopup.releaseReservation,
2163 probe: async (url: string): Promise<DcrProbeResult | null> => {2215 probe: async (url: string): Promise<DcrProbeResult | null> => {
2164 const exit = await doProbe({ payload: { url }, reactivityKeys: [] });2216 const exit = await doProbe({ payload: { url }, reactivityKeys: [] });
2165 if (Exit.isFailure(exit)) return null;2217 if (Exit.isFailure(exit)) return null;
@@ -2194,6 +2246,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 28 unmodified lines
2194 },2246 },
2195 start: (args: DcrStartArgs): void => {2247 start: (args: DcrStartArgs): void => {
2196 void oauthPopup.start({2248 void oauthPopup.start({
2249 reservation: args.reservation,
2197 payload: {2250 payload: {
2198 client: args.client,2251 client: args.client,
2199 // DCR registers the client under the connection owner, so the app2252 // DCR registers the client under the connection owner, so the app
@@ -2236,6 +2289,10 @@ function AddAccountModalView(props: AddAccountModalProps) { · 36 unmodified lines
2236 success: outcome.kind === "started",2289 success: outcome.kind === "started",
2237 ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),2290 ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),
2238 });2291 });
2292 // Deliberately absent: a "popup-blocked" branch. Registering an app by hand
2293 // does not make the browser open a window, so dropping to the BYO picker
2294 // would send the user down a path that cannot succeed either. `reserve`
2295 // already put the reason in `oauthPopup.error`, which the footer renders.
2239 if (outcome.kind === "fallback") {2296 if (outcome.kind === "fallback") {
2240 setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);2297 setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
2241 setDcrFailed(true);2298 setDcrFailed(true);
@@ -2497,17 +2554,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 255 unmodified lines
2497 : `${integrationName} supports Client ID Metadata Document OAuth. We'll use this Executor host's public client metadata document and sign you in.`}2554 : `${integrationName} supports Client ID Metadata Document OAuth. We'll use this Executor host's public client metadata document and sign you in.`}
2498 </p>2555 </p>
2499 </div>2556 </div>
2500 ) : dcrActive ? (
2501 // Transparent DCR: no picker. We register an app for you and run
2502 // the OAuth flow with a single Connect click.
2503 <div className="space-y-2 rounded-lg border border-ring/40 bg-accent/30 px-3 py-3">
2504 <p className="text-sm font-medium">No app to choose</p>
2505 <p className="text-xs text-muted-foreground">
2506 {dcrConnecting
2507 ? `Connecting to ${integrationName}…`
2508 : `${integrationName} supports automatic setup. We register an app for you and sign you in — no client ID or app to pick.`}
2509 </p>
2510 </div>
2511 ) : oauthLoading ? (2557 ) : oauthLoading ? (
2512 <p className="text-xs text-muted-foreground">Loading OAuth apps…</p>2558 <p className="text-xs text-muted-foreground">Loading OAuth apps…</p>
2513 ) : (2559 ) : (
@@ -2739,9 +2785,6 @@ function AddAccountModalView(props: AddAccountModalProps) { · 225 unmodified lines
2739 />2785 />
2740 </div>2786 </div>
2741 )}2787 )}
2742 {isOAuth && oauthPopup.error ? (
2743 <p className="text-xs text-destructive">{oauthPopup.error}</p>
2744 ) : null}
2745 </div>2788 </div>
2746 )}2789 )}
2747 </TabsContent>2790 </TabsContent>
@@ -2909,6 +2952,15 @@ function AddAccountModalView(props: AddAccountModalProps) { · 161 unmodified lines
2909 {continueError}2952 {continueError}
2910 </p>2953 </p>
2911 ) : null}2954 ) : null}
2955 {/* Above the footer, not inside the method tab: the automatic
2956 (CIMD/DCR) flows render no tab panel at all, and putting the
2957 sign-in error in there left a blocked popup with nothing on
2958 screen but the button returning to "Connect". */}
2959 {isOAuth && oauthPopup.error ? (
2960 <p role="alert" className="px-1 text-xs text-destructive">
2961 {oauthPopup.error}
2962 </p>
2963 ) : null}
2912 <DialogFooter>2964 <DialogFooter>
2913 <Button2965 <Button
2914 type="button"2966 type="button"
packages/react/src/plugins/oauth-sign-in.tsx+90−20
@@ -12,11 +12,12 @@ import { · 11 unmodified lines
12 openOAuthSystemBrowser,12 openOAuthSystemBrowser,
13 reserveOAuthPopup,13 reserveOAuthPopup,
14 type OAuthPopupResult,14 type OAuthPopupResult,
15 type ReservedOAuthPopup,
15} from "../api/oauth-popup";16} from "../api/oauth-popup";
16import { connectionWriteKeys } from "../api/reactivity-keys";17import { connectionWriteKeys } from "../api/reactivity-keys";
17import { getActiveOrgSlug } from "../api/server-connection";18import { getActiveOrgSlug } from "../api/server-connection";
18 19
19type DesktopBridge = {20export type DesktopBridge = {
20 readonly openExternal: (url: string) => Promise<void>;21 readonly openExternal: (url: string) => Promise<void>;
21};22};
22 23
@@ -80,8 +81,31 @@ export type StartOAuthPopupInput<TPayload extends OAuthCompletionPayload> = { · 57 unmodified lines
80 readonly onSuccess: (payload: TPayload) => void | Promise<void>;81 readonly onSuccess: (payload: TPayload) => void | Promise<void>;
81 readonly onError?: (error: string) => void;82 readonly onError?: (error: string) => void;
82 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;83 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;
84 /** Window reserved on the click that began this flow. See `reserve`. */
85 readonly reservation?: OAuthPopupReservation;
83};86};
84 87
88/**
89 * A sign-in window claimed synchronously inside a click handler.
90 *
91 * `window.open` requires transient user activation, which Chromium expires
92 * about five seconds after the click. Any flow that awaits the network before
93 * it has an authorization URL (DCR probes then registers, CIMD creates a
94 * client, reconnect starts a session) therefore cannot open the window when the
95 * URL finally arrives: by then the activation is gone and the browser refuses
96 * silently. Those flows reserve a blank window on the click and navigate it
97 * later, however long the round trips take.
98 *
99 * Desktop hosts hand the URL to the real browser, so they reserve nothing and
100 * carry the bridge that will open it instead.
101 */
102export type OAuthPopupReservation =
103 | { readonly kind: "desktop"; readonly bridge: DesktopBridge }
104 | { readonly kind: "window"; readonly popup: ReservedOAuthPopup }
105 | { readonly kind: "blocked" };
106
107const POPUP_BLOCKED_MESSAGE = "Sign-in popup was blocked by the browser";
108
85export type OAuthAuthorizationStartResult = {109export type OAuthAuthorizationStartResult = {
86 /** OAuth correlation token (was the v1 session id). */110 /** OAuth correlation token (was the v1 session id). */
87 readonly state: string;111 readonly state: string;
@@ -100,6 +124,8 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 12 unmodified lines
100 readonly onError?: (error: string, details?: string) => void;124 readonly onError?: (error: string, details?: string) => void;
101 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;125 readonly onAuthorizationStarted?: (result: OAuthAuthorizationStartResult) => void;
102 readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>;126 readonly reportMetadata?: Record<string, string | number | boolean | null | undefined>;
127 /** Window reserved on the click that began this flow. See `reserve`. */
128 readonly reservation?: OAuthPopupReservation;
103};129};
104 130
105export function oauthCallbackUrl(path = "/api/oauth/callback"): string {131export function oauthCallbackUrl(path = "/api/oauth/callback"): string {
@@ -164,10 +190,21 @@ export function useOAuthPopupFlow< · 58 unmodified lines
164 const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });190 const doCancelOAuth = useAtomSet(cancelOAuth, { mode: "promiseExit" });
165 const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" });191 const doOAuthConnectionCompleted = useAtomSet(oauthConnectionCompleted, { mode: "promiseExit" });
166 const reportHandledError = useReportHandledError();192 const reportHandledError = useReportHandledError();
193 const blockedMessage = popupBlockedMessage ?? POPUP_BLOCKED_MESSAGE;
167 const [busy, setBusy] = useState(false);194 const [busy, setBusy] = useState(false);
168 const [error, setError] = useState<string | null>(null);195 const [error, setError] = useState<string | null>(null);
169 const cleanupRef = useRef<(() => void) | null>(null);196 const cleanupRef = useRef<(() => void) | null>(null);
170 const sessionRef = useRef<{ readonly state: string } | null>(null);197 const sessionRef = useRef<{ readonly state: string } | null>(null);
198 // A window reserved on the click but not yet handed to a flow owns nothing
199 // that would close it: `cleanupRef` is only set once the authorization URL
200 // exists. Hold it here so cancel and unmount can close it, or abandoning a
201 // connect mid-probe strands a blank popup on screen.
202 const reservationRef = useRef<ReservedOAuthPopup | null>(null);
203
204 const releaseReservation = useCallback(() => {
205 reservationRef.current?.popup.close();
206 reservationRef.current = null;
207 }, []);
171 208
172 const cancelSession = useCallback(209 const cancelSession = useCallback(
173 (state: string) => {210 (state: string) => {
@@ -181,9 +218,10 @@ export function useOAuthPopupFlow< · 7 unmodified lines
181 cleanupRef.current?.();218 cleanupRef.current?.();
182 cleanupRef.current = null;219 cleanupRef.current = null;
183 sessionRef.current = null;220 sessionRef.current = null;
221 releaseReservation();
184 if (session) cancelSession(session.state);222 if (session) cancelSession(session.state);
185 setBusy(false);223 setBusy(false);
186 }, [cancelSession]);224 }, [cancelSession, releaseReservation]);
187 225
188 useEffect(226 useEffect(
189 () => () => {227 () => () => {
@@ -191,29 +229,59 @@ export function useOAuthPopupFlow< · 1 unmodified line
191 cleanupRef.current?.();229 cleanupRef.current?.();
192 cleanupRef.current = null;230 cleanupRef.current = null;
193 sessionRef.current = null;231 sessionRef.current = null;
232 reservationRef.current?.popup.close();
233 reservationRef.current = null;
194 if (session) cancelSession(session.state);234 if (session) cancelSession(session.state);
195 },235 },
196 [cancelSession],236 [cancelSession],
197 );237 );
198 238
239 /**
240 * Claim the sign-in window NOW, while the click still carries user
241 * activation, and return what was claimed so the caller can abandon a flow
242 * the browser already refused instead of running a doomed authorization.
243 *
244 * Callers that reach `openAuthorization` synchronously do not need this; it
245 * reserves for itself.
246 */
247 const reserve = useCallback((): OAuthPopupReservation => {
248 // Tear the previous flow down BEFORE opening: `window.open` reuses a window
249 // already carrying `popupName`, so cancelling afterwards would close the
250 // very window just reserved.
251 cancel();
252 const bridge = getDesktopBridge();
253 if (bridge !== null) return { kind: "desktop", bridge };
254 const popup = reserveOAuthPopup({ popupName });
255 if (popup === null) {
256 trackEvent("oauth_popup_blocked");
257 setError(blockedMessage);
258 return { kind: "blocked" };
259 }
260 reservationRef.current = popup;
261 return { kind: "window", popup };
262 }, [blockedMessage, cancel, popupName]);
263
199 const openAuthorization = useCallback(264 const openAuthorization = useCallback(
200 async (input: StartOAuthAuthorizationInput<TPayload>) => {265 async (input: StartOAuthAuthorizationInput<TPayload>) => {
201 cancel();266 // A caller that reserved on its own click already ran `cancel` inside
202 setBusy(true);267 // `reserve`; cancelling again here would close the window it reserved.
203 setError(null);268 const reservation = input.reservation ?? reserve();
204 const desktopBridge = getDesktopBridge();269 if (reservation.kind === "blocked") {
205 // Desktop hosts open the auth URL in the user's real browser, so we skip
206 // the in-page popup reservation entirely and rely on the polling channel
207 // for the result.
208 const reservedPopup = desktopBridge ? null : reserveOAuthPopup({ popupName });
209 if (!desktopBridge && !reservedPopup) {
210 const message = popupBlockedMessage ?? "Sign-in popup was blocked by the browser";
211 trackEvent("oauth_popup_blocked");
212 setBusy(false);270 setBusy(false);
213 setError(message);271 setError(blockedMessage);
214 input.onError?.(message);272 input.onError?.(blockedMessage);
215 return;273 return;
216 }274 }
275 setBusy(true);
276 setError(null);
277 // Desktop hosts open the auth URL in the user's real browser, so they
278 // reserve no in-page window and rely on the polling channel for the
279 // result.
280 const desktopBridge = reservation.kind === "desktop" ? reservation.bridge : null;
281 const reservedPopup = reservation.kind === "window" ? reservation.popup : null;
282 // The window's lifetime now belongs to this flow's teardown, which closes
283 // it on every failure path below.
284 reservationRef.current = null;
217 const startExit = await Effect.runPromiseExit(285 const startExit = await Effect.runPromiseExit(
218 Effect.tryPromise({286 Effect.tryPromise({
219 try: input.run,287 try: input.run,
@@ -319,11 +387,10 @@ export function useOAuthPopupFlow< · 99 unmodified lines
319 cleanupRef.current = null;387 cleanupRef.current = null;
320 sessionRef.current = null;388 sessionRef.current = null;
321 cancelSession(response.state);389 cancelSession(response.state);
322 const message = popupBlockedMessage ?? "Sign-in popup was blocked by the browser";
323 trackEvent("oauth_completed", { success: false });390 trackEvent("oauth_completed", { success: false });
324 setBusy(false);391 setBusy(false);
325 setError(message);392 setError(blockedMessage);
326 input.onError?.(message);393 input.onError?.(blockedMessage);
327 };394 };
328 395
329 cleanupRef.current = desktopBridge396 cleanupRef.current = desktopBridge
@@ -348,15 +415,15 @@ export function useOAuthPopupFlow< · 18 unmodified lines
348 });415 });
349 },416 },
350 [417 [
351 cancel,418 blockedMessage,
352 cancelSession,419 cancelSession,
353 detectPopupClosed,420 detectPopupClosed,
354 doOAuthConnectionCompleted,421 doOAuthConnectionCompleted,
355 noAuthorizationUrlMessage,422 noAuthorizationUrlMessage,
356 popupBlockedMessage,
357 popupClosedMessage,423 popupClosedMessage,
358 popupName,424 popupName,
359 reportHandledError,425 reportHandledError,
426 reserve,
360 startErrorMessage,427 startErrorMessage,
361 ],428 ],
362 );429 );
@@ -368,6 +435,7 @@ export function useOAuthPopupFlow< · 5 unmodified lines
368 onSuccess: input.onSuccess,435 onSuccess: input.onSuccess,
369 onError: input.onError,436 onError: input.onError,
370 onAuthorizationStarted: input.onAuthorizationStarted,437 onAuthorizationStarted: input.onAuthorizationStarted,
438 ...(input.reservation === undefined ? {} : { reservation: input.reservation }),
371 reportMetadata: {439 reportMetadata: {
372 client: String(input.payload.client),440 client: String(input.payload.client),
373 integration: String(input.payload.integration),441 integration: String(input.payload.integration),
@@ -414,6 +482,8 @@ export function useOAuthPopupFlow< · 40 unmodified lines
414 start,482 start,
415 openAuthorization,483 openAuthorization,
416 cancel,484 cancel,
485 reserve,
486 releaseReservation,
417 };487 };
418}488}
419 489