History

Add OAuth popup UI utilities to @executor/plugin-oauth2 (#248)

Split the package into three entrypoints (`.`, `/http`, `/react`) so browser consumers do not pull Node crypto. New surface: - `OAuthPopupResult<TAuth>` + `isOAuthPopupResult` in a shared types module so both the server-side HTML generator and the client-side popup opener agree on the message shape. - `/http` entry: `popupDocument(payload, channelName)` HTML generator (with XSS-safe serialization, dark-mode CSS, postMessage + BroadcastChannel fallback), and `runOAuthCallback` wrapper that turns a `completeOAuth` Effect into the HTML body for the redirect route. - `/react` entry: `openOAuthPopup` browser util with centered-popup math, same-origin message filtering, settle-once semantics, and popup-blocked detection. Ports `google-discovery` to use them: `api/handlers.ts` drops its inline `popupDocument` / OAuth glue and becomes a ~20-line handler, and `AddGoogleDiscoverySource.tsx` drops its inline `openOAuthPopup`. 20 new fidelity tests lock every behavior (XSS escaping, channel name escape, origin filtering, settle-once across both channels, popup block handling).

Rhys Sullivan committed 7 months ago

Commit 2e1f75b1 parent 3bcfc2f10 files · +911 −142Browse files

Unified · Split

10 files
  1. packages/plugins/google-discovery/src/api/handlers.ts +19 −74
  2. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +14 −66
  3. packages/plugins/oauth2/package.json +15 −1
  4. packages/plugins/oauth2/src/http.test.ts +201 −0
  5. packages/plugins/oauth2/src/http.ts +145 −0
  6. packages/plugins/oauth2/src/popup-types.ts +30 −0
  7. packages/plugins/oauth2/src/react.test.ts +339 −0
  8. packages/plugins/oauth2/src/react.ts +145 −0
  9. packages/plugins/oauth2/tsconfig.json +1 −1
  10. packages/plugins/oauth2/tsup.config.ts +2 −0
packages/plugins/google-discovery/src/api/handlers.ts+19−74
@@ -1,6 +1,8 @@
11 import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
22 import { Cause, Context, Effect } from "effect";
33
4+import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5+
46 import { addGroup } from "@executor/api";
57 import type {
68 GoogleDiscoveryAddSourceInput,
@@ -26,55 +28,7 @@ export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscover · 19 unmodified lines
2628
2729 const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
2830
29−type OAuthPopupResult =
30− | ({
31− type: "executor:oauth-result";
32− ok: true;
33− sessionId: string;
34− } & GoogleDiscoveryOAuthAuthResult)
35− | {
36− type: "executor:oauth-result";
37− ok: false;
38− sessionId: null;
39− error: string;
40− };
41−
42−const escapeHtml = (value: string): string =>
43− value
44− .replaceAll("&", "&amp;")
45− .replaceAll("<", "&lt;")
46− .replaceAll(">", "&gt;")
47− .replaceAll('"', "&quot;")
48− .replaceAll("'", "&#39;");
49−
50−const popupDocument = (payload: OAuthPopupResult): string => {
51− const serialized = JSON.stringify(payload)
52− .replaceAll("<", "\\u003c")
53− .replaceAll(">", "\\u003e")
54− .replaceAll("&", "\\u0026");
55− const title = payload.ok ? "Connected" : "Connection failed";
56− const message = payload.ok
57− ? "Authentication complete. This window will close automatically."
58− : payload.error;
59− const statusColor = payload.ok ? "#22c55e" : "#ef4444";
60−
61− return `<!doctype html>
62−<html lang="en">
63−<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeHtml(title)}</title></head>
64−<body style="font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#111">
65−<style>@media(prefers-color-scheme:dark){body{background:#09090b!important;color:#fafafa!important}p{color:#a1a1aa!important}}</style>
66−<main style="text-align:center;max-width:360px;padding:24px">
67−<div style="width:40px;height:40px;border-radius:50%;background:${statusColor};margin:0 auto 16px;display:flex;align-items:center;justify-content:center">
68−<svg width="20" height="20" viewBox="0 0 20 20" fill="none">${payload.ok ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'}</svg>
69−</div>
70−<h1 style="margin:0 0 8px;font-size:18px;font-weight:600">${escapeHtml(title)}</h1>
71−<p style="margin:0;font-size:14px;color:#666;line-height:1.5">${escapeHtml(message)}</p>
72−</main>
73−<script>
74−(()=>{const p=${serialized};try{if(window.opener)window.opener.postMessage(p,window.location.origin);if("BroadcastChannel"in window){const c=new BroadcastChannel("executor:google-discovery-oauth-result");c.postMessage(p);setTimeout(()=>c.close(),100)}}finally{setTimeout(()=>window.close(),150)}})();
75−</script>
76−</body></html>`;
77−};
31+const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";
7832
7933 const toPopupErrorMessage = (error: unknown): string => {
8034 if (error instanceof GoogleDiscoveryOAuthError) {
@@ -156,31 +110,22 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 75 unmodified lines
156110 .handle("oauthCallback", ({ urlParams }) =>
157111 Effect.gen(function* () {
158112 const ext = yield* GoogleDiscoveryExtensionService;
159− const result = yield* ext
160− .completeOAuth({
161− state: urlParams.state,
162− code: urlParams.code,
163− error: urlParams.error ?? urlParams.error_description,
164− })
165− .pipe(
166− Effect.map(
167− (auth): OAuthPopupResult => ({
168− type: "executor:oauth-result",
169− ok: true,
170− sessionId: urlParams.state,
171− ...auth,
172− }),
173− ),
174− Effect.catchAllCause((cause) =>
175− Effect.succeed<OAuthPopupResult>({
176− type: "executor:oauth-result",
177− ok: false,
178− sessionId: null,
179− error: toPopupErrorMessage(Cause.squash(cause)),
180− }),
181− ),
182− );
183− return yield* HttpServerResponse.html(popupDocument(result));
113+ const html = yield* runOAuthCallback<
114+ GoogleDiscoveryOAuthAuthResult,
115+ GoogleDiscoveryOAuthError,
116+ never
117+ >({
118+ complete: ({ state, code, error }) =>
119+ ext.completeOAuth({
120+ state,
121+ code: code ?? undefined,
122+ error: error ?? undefined,
123+ }),
124+ urlParams,
125+ toErrorMessage: toPopupErrorMessage,
126+ channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,
127+ });
128+ return yield* HttpServerResponse.html(html);
184129 }).pipe(sanitizeGoogleDiscoveryFailure),
185130 ),
186131 );
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+14−66
@@ -1,6 +1,11 @@
11 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
33
4+import {
5+ openOAuthPopup,
6+ type OAuthPopupResult,
7+} from "@executor/plugin-oauth2/react";
8+
49 import { secretsAtom, setSecret } from "@executor/react/api/atoms";
510 import { useScope } from "@executor/react/api/scope-context";
611 import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";
@@ -373,69 +378,10 @@ type OAuthAuth = { · 366 unmodified lines
373378 scopes: string[];
374379 };
375380
376−type OAuthPopupResult =
377− | ({
378− type: "executor:oauth-result";
379− ok: true;
380− sessionId: string;
381− } & OAuthAuth)
382− | {
383− type: "executor:oauth-result";
384− ok: false;
385− sessionId: null;
386− error: string;
387− };
381+type GoogleOAuthPopupResult = OAuthPopupResult<OAuthAuth>;
388382
389383 const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result";
390−
391−const isOAuthPopupResult = (value: unknown): value is OAuthPopupResult =>
392− typeof value === "object" &&
393− value !== null &&
394− (value as { type?: unknown }).type === "executor:oauth-result";
395−
396−function openOAuthPopup(
397− url: string,
398− onResult: (data: OAuthPopupResult) => void,
399− onOpenFailed?: () => void,
400−): () => void {
401− const w = 640;
402− const h = 760;
403− const left = window.screenX + (window.outerWidth - w) / 2;
404− const top = window.screenY + (window.outerHeight - h) / 2;
405−
406− let settled = false;
407− const channel =
408− typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(OAUTH_RESULT_CHANNEL) : null;
409− const settle = () => {
410− if (settled) return;
411− settled = true;
412− window.removeEventListener("message", onMessage);
413− channel?.close();
414− };
415−
416− const handleResult = (data: unknown) => {
417− if (!isOAuthPopupResult(data) || settled) return;
418− settle();
419− onResult(data);
420− };
421−
422− const onMessage = (event: MessageEvent) => {
423− if (event.origin === window.location.origin) handleResult(event.data);
424− };
425− window.addEventListener("message", onMessage);
426− if (channel) channel.onmessage = (event) => handleResult(event.data);
427−
428− const popup = window.open(
429− url,
430− "google-discovery-oauth",
431− `width=${w},height=${h},left=${left},top=${top},popup=1`,
432− );
433− if (!popup && !settled) {
434− settle();
435− queueMicrotask(() => onOpenFailed?.());
436− }
437− return settle;
438−}
384+const OAUTH_POPUP_NAME = "google-discovery-oauth";
439385
440386 export default function AddGoogleDiscoverySource(props: {
441387 readonly onComplete: () => void;
@@ -565,9 +511,11 @@ export default function AddGoogleDiscoverySource(props: { · 123 unmodified lines
565511 },
566512 });
567513
568− oauthCleanup.current = openOAuthPopup(
569− response.authorizationUrl,
570− (result) => {
514+ oauthCleanup.current = openOAuthPopup<OAuthAuth>({
515+ url: response.authorizationUrl,
516+ popupName: OAUTH_POPUP_NAME,
517+ channelName: OAUTH_RESULT_CHANNEL,
518+ onResult: (result: GoogleOAuthPopupResult) => {
571519 oauthCleanup.current = null;
572520 setStartingOAuth(false);
573521 if (result.ok) {
@@ -587,12 +535,12 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines
587535 setError(result.error);
588536 }
589537 },
590− () => {
538+ onOpenFailed: () => {
591539 oauthCleanup.current = null;
592540 setStartingOAuth(false);
593541 setError("OAuth popup was blocked");
594542 },
595− );
543+ });
596544 } catch (e) {
597545 setStartingOAuth(false);
598546 setError(e instanceof Error ? e.message : "Failed to start OAuth");
packages/plugins/oauth2/package.json+15−1
@@ -16,7 +16,9 @@ · 15 unmodified lines
1616 ],
1717 "type": "module",
1818 "exports": {
19− ".": "./src/index.ts"
19+ ".": "./src/index.ts",
20+ "./http": "./src/http.ts",
21+ "./react": "./src/react.ts"
2022 },
2123 "publishConfig": {
2224 "access": "public",
@@ -26,6 +28,18 @@ · 3 unmodified lines
2628 "types": "./dist/index.d.ts",
2729 "default": "./dist/index.js"
2830 }
31+ },
32+ "./http": {
33+ "import": {
34+ "types": "./dist/http.d.ts",
35+ "default": "./dist/http.js"
36+ }
37+ },
38+ "./react": {
39+ "import": {
40+ "types": "./dist/react.d.ts",
41+ "default": "./dist/react.js"
42+ }
2943 }
3044 }
3145 },
packages/plugins/oauth2/src/http.test.ts+201−0
@@ -0,0 +1,201 @@
1+// ---------------------------------------------------------------------------
2+// Fidelity tests for the OAuth popup HTML generator + callback wrapper.
3+// Locks in the escaping rules, postMessage/BroadcastChannel behavior, and
4+// the completeOAuth-to-popup-payload conversion semantics so the google-
5+// discovery port is provably behavior-preserving.
6+// ---------------------------------------------------------------------------
7+
8+import { describe, expect, it } from "vitest";
9+import { Effect } from "effect";
10+
11+import {
12+ OAUTH_POPUP_MESSAGE_TYPE,
13+ popupDocument,
14+ runOAuthCallback,
15+ type OAuthPopupResult,
16+} from "./http";
17+
18+type GoogleAuth = {
19+ kind: "oauth2";
20+ accessTokenSecretId: string;
21+ refreshTokenSecretId: string | null;
22+};
23+
24+// ---------------------------------------------------------------------------
25+// popupDocument
26+// ---------------------------------------------------------------------------
27+
28+describe("popupDocument", () => {
29+ const successPayload: OAuthPopupResult<GoogleAuth> = {
30+ type: OAUTH_POPUP_MESSAGE_TYPE,
31+ ok: true,
32+ sessionId: "session-abc",
33+ kind: "oauth2",
34+ accessTokenSecretId: "secret_1",
35+ refreshTokenSecretId: "secret_2",
36+ };
37+
38+ it("renders a success page with Connected title and the green check icon", () => {
39+ const html = popupDocument(successPayload, "channel-x");
40+ expect(html).toContain("<title>Connected</title>");
41+ expect(html).toContain("#22c55e");
42+ expect(html).toContain("Authentication complete");
43+ });
44+
45+ it("renders a failure page with the error text escaped", () => {
46+ const html = popupDocument(
47+ {
48+ type: OAUTH_POPUP_MESSAGE_TYPE,
49+ ok: false,
50+ sessionId: null,
51+ error: "Token endpoint returned 400 <script>alert(1)</script>",
52+ },
53+ "channel-x",
54+ );
55+ expect(html).toContain("<title>Connection failed</title>");
56+ expect(html).toContain("#ef4444");
57+ expect(html).toContain("Token endpoint returned 400 &lt;script&gt;alert(1)&lt;/script&gt;");
58+ expect(html).not.toContain("<script>alert(1)</script>");
59+ });
60+
61+ it("HTML-escapes the BroadcastChannel name so attacker-controlled names cannot break out", () => {
62+ const html = popupDocument(successPayload, 'evil"name');
63+ expect(html).toContain('new BroadcastChannel("evil&quot;name")');
64+ });
65+
66+ it("escapes < > & in the serialized script payload to prevent </script> breakout", () => {
67+ const html = popupDocument(
68+ {
69+ type: OAUTH_POPUP_MESSAGE_TYPE,
70+ ok: false,
71+ sessionId: null,
72+ error: "</script><img/src=x onerror=alert(1)>",
73+ },
74+ "channel-x",
75+ );
76+ // The raw `</script>` must not appear in the inline script literal.
77+ const scriptLiteralMatch = /const p=(\{.*?\});/.exec(html);
78+ expect(scriptLiteralMatch).not.toBeNull();
79+ const scriptLiteral = scriptLiteralMatch![1]!;
80+ expect(scriptLiteral).not.toContain("</script>");
81+ expect(scriptLiteral).toContain("\\u003c/script\\u003e");
82+ });
83+
84+ it("posts to window.opener AND falls back to BroadcastChannel with the given channel name", () => {
85+ const html = popupDocument(successPayload, "executor:openapi-oauth-result");
86+ expect(html).toContain("window.opener.postMessage(p,window.location.origin)");
87+ expect(html).toContain('new BroadcastChannel("executor:openapi-oauth-result")');
88+ expect(html).toContain("window.close()");
89+ });
90+
91+ it("includes dark-mode CSS", () => {
92+ const html = popupDocument(successPayload, "c");
93+ expect(html).toContain("@media(prefers-color-scheme:dark)");
94+ });
95+});
96+
97+// ---------------------------------------------------------------------------
98+// runOAuthCallback
99+// ---------------------------------------------------------------------------
100+
101+describe("runOAuthCallback", () => {
102+ it("renders a success popup when completeOAuth succeeds", async () => {
103+ const html = await Effect.runPromise(
104+ runOAuthCallback<GoogleAuth, never, never>({
105+ complete: () =>
106+ Effect.succeed({
107+ kind: "oauth2",
108+ accessTokenSecretId: "s1",
109+ refreshTokenSecretId: "s2",
110+ }),
111+ urlParams: { state: "session-xyz", code: "abc" },
112+ toErrorMessage: () => "should not reach",
113+ channelName: "channel-x",
114+ }),
115+ );
116+ expect(html).toContain("<title>Connected</title>");
117+ expect(html).toContain("session-xyz");
118+ expect(html).toContain("s1");
119+ });
120+
121+ it("passes code and error through to the complete callback verbatim", async () => {
122+ const received: Array<{ state: string; code: string | null; error: string | null }> = [];
123+ await Effect.runPromise(
124+ runOAuthCallback<GoogleAuth, never, never>({
125+ complete: (params) => {
126+ received.push(params);
127+ return Effect.succeed({
128+ kind: "oauth2",
129+ accessTokenSecretId: "s",
130+ refreshTokenSecretId: null,
131+ });
132+ },
133+ urlParams: { state: "s1", code: "code1", error: null },
134+ toErrorMessage: () => "",
135+ channelName: "c",
136+ }),
137+ );
138+ expect(received).toEqual([{ state: "s1", code: "code1", error: null }]);
139+ });
140+
141+ it("prefers `error` over `error_description` but falls back when absent", async () => {
142+ const received: Array<{ state: string; code: string | null; error: string | null }> = [];
143+ const complete = (params: { state: string; code: string | null; error: string | null }) => {
144+ received.push(params);
145+ return Effect.succeed({
146+ kind: "oauth2" as const,
147+ accessTokenSecretId: "s",
148+ refreshTokenSecretId: null,
149+ });
150+ };
151+ await Effect.runPromise(
152+ runOAuthCallback<GoogleAuth, never, never>({
153+ complete,
154+ urlParams: { state: "s1", error: "access_denied" },
155+ toErrorMessage: () => "",
156+ channelName: "c",
157+ }),
158+ );
159+ await Effect.runPromise(
160+ runOAuthCallback<GoogleAuth, never, never>({
161+ complete,
162+ urlParams: { state: "s2", error_description: "user cancelled" },
163+ toErrorMessage: () => "",
164+ channelName: "c",
165+ }),
166+ );
167+ expect(received[0]!.error).toBe("access_denied");
168+ expect(received[1]!.error).toBe("user cancelled");
169+ });
170+
171+ it("renders a failure popup when completeOAuth fails and uses toErrorMessage", async () => {
172+ class DomainError {
173+ readonly _tag = "DomainError";
174+ constructor(readonly message: string) {}
175+ }
176+ const html = await Effect.runPromise(
177+ runOAuthCallback<GoogleAuth, DomainError, never>({
178+ complete: () => Effect.fail(new DomainError("Code expired")),
179+ urlParams: { state: "s1" },
180+ toErrorMessage: (error) =>
181+ error instanceof DomainError ? error.message : "unknown",
182+ channelName: "c",
183+ }),
184+ );
185+ expect(html).toContain("<title>Connection failed</title>");
186+ expect(html).toContain("Code expired");
187+ });
188+
189+ it("never rejects — even defects are rendered as a failure popup", async () => {
190+ const html = await Effect.runPromise(
191+ runOAuthCallback<GoogleAuth, never, never>({
192+ complete: () => Effect.die("boom"),
193+ urlParams: { state: "s1" },
194+ toErrorMessage: () => "transport error",
195+ channelName: "c",
196+ }),
197+ );
198+ expect(html).toContain("<title>Connection failed</title>");
199+ expect(html).toContain("transport error");
200+ });
201+});
packages/plugins/oauth2/src/http.ts+145−0
@@ -0,0 +1,145 @@
1+// ---------------------------------------------------------------------------
2+// @executor/plugin-oauth2/http — server-side HTTP helpers for OAuth popups.
3+//
4+// `popupDocument` renders the HTML page returned by the OAuth redirect
5+// handler. The page immediately `postMessage`s the result back to the
6+// opener window and falls back to a `BroadcastChannel` if the opener is
7+// gone (mobile Safari closes the opener on popup open in some cases).
8+//
9+// `runOAuthCallback` wraps the "call completeOAuth, turn Exit into
10+// popup payload, render HTML" glue so plugin handlers stay one-liners.
11+// ---------------------------------------------------------------------------
12+
13+import { Cause, Effect } from "effect";
14+
15+import {
16+ OAUTH_POPUP_MESSAGE_TYPE,
17+ type OAuthPopupResult,
18+} from "./popup-types";
19+
20+export { OAUTH_POPUP_MESSAGE_TYPE, isOAuthPopupResult } from "./popup-types";
21+export type { OAuthPopupResult } from "./popup-types";
22+
23+// ---------------------------------------------------------------------------
24+// HTML generation
25+// ---------------------------------------------------------------------------
26+
27+const escapeHtml = (value: string): string =>
28+ value
29+ .replaceAll("&", "&amp;")
30+ .replaceAll("<", "&lt;")
31+ .replaceAll(">", "&gt;")
32+ .replaceAll('"', "&quot;")
33+ .replaceAll("'", "&#39;");
34+
35+/**
36+ * Serialize for embedding inside a `<script>` tag. Escapes the characters
37+ * that could prematurely terminate the script or mislead an HTML parser
38+ * (`<`, `>`, `&`) so an attacker-controlled `error` field can't break out.
39+ */
40+const serializeForScript = (value: unknown): string =>
41+ JSON.stringify(value)
42+ .replaceAll("<", "\\u003c")
43+ .replaceAll(">", "\\u003e")
44+ .replaceAll("&", "\\u0026");
45+
46+/**
47+ * Render the HTML page that the OAuth redirect returns. The page is
48+ * intentionally dependency-free: inline CSS, dark-mode support via
49+ * `prefers-color-scheme`, and a small inline script that posts the result
50+ * back to the opener via `postMessage` + `BroadcastChannel` then closes
51+ * itself.
52+ */
53+export const popupDocument = <TAuth>(
54+ payload: OAuthPopupResult<TAuth>,
55+ channelName: string,
56+): string => {
57+ const serialized = serializeForScript(payload);
58+ const title = payload.ok ? "Connected" : "Connection failed";
59+ const message = payload.ok
60+ ? "Authentication complete. This window will close automatically."
61+ : payload.error;
62+ const statusColor = payload.ok ? "#22c55e" : "#ef4444";
63+ const icon = payload.ok
64+ ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
65+ : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>';
66+ const escapedChannel = escapeHtml(channelName);
67+
68+ return `<!doctype html>
69+<html lang="en">
70+<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeHtml(title)}</title></head>
71+<body style="font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#111">
72+<style>@media(prefers-color-scheme:dark){body{background:#09090b!important;color:#fafafa!important}p{color:#a1a1aa!important}}</style>
73+<main style="text-align:center;max-width:360px;padding:24px">
74+<div style="width:40px;height:40px;border-radius:50%;background:${statusColor};margin:0 auto 16px;display:flex;align-items:center;justify-content:center">
75+<svg width="20" height="20" viewBox="0 0 20 20" fill="none">${icon}</svg>
76+</div>
77+<h1 style="margin:0 0 8px;font-size:18px;font-weight:600">${escapeHtml(title)}</h1>
78+<p style="margin:0;font-size:14px;color:#666;line-height:1.5">${escapeHtml(message)}</p>
79+</main>
80+<script>
81+(()=>{const p=${serialized};try{if(window.opener)window.opener.postMessage(p,window.location.origin);if("BroadcastChannel"in window){const c=new BroadcastChannel("${escapedChannel}");c.postMessage(p);setTimeout(()=>c.close(),100)}}finally{setTimeout(()=>window.close(),150)}})();
82+</script>
83+</body></html>`;
84+};
85+
86+// ---------------------------------------------------------------------------
87+// Callback wrapper — turns a completeOAuth Effect into a popup HTML string.
88+// ---------------------------------------------------------------------------
89+
90+export type OAuthCallbackUrlParams = {
91+ readonly state: string;
92+ readonly code?: string | null;
93+ readonly error?: string | null;
94+ readonly error_description?: string | null;
95+};
96+
97+export type RunOAuthCallbackInput<TAuth, E, R> = {
98+ /** The plugin's `completeOAuth` — resolves to the auth descriptor on success. */
99+ readonly complete: (params: {
100+ readonly state: string;
101+ readonly code: string | null;
102+ readonly error: string | null;
103+ }) => Effect.Effect<TAuth, E, R>;
104+ readonly urlParams: OAuthCallbackUrlParams;
105+ /** Map a plugin-specific error into a user-facing message. */
106+ readonly toErrorMessage: (error: unknown) => string;
107+ readonly channelName: string;
108+};
109+
110+/**
111+ * Run a plugin's `completeOAuth` against URL params from the OAuth redirect,
112+ * wrap the success / failure in an `OAuthPopupResult`, and return the HTML
113+ * body ready to hand to `HttpServerResponse.html(...)`.
114+ *
115+ * This never fails — errors become a `{ ok: false }` result so the popup
116+ * can still render and close itself.
117+ */
118+export const runOAuthCallback = <TAuth, E, R>(
119+ input: RunOAuthCallbackInput<TAuth, E, R>,
120+): Effect.Effect<string, never, R> =>
121+ input
122+ .complete({
123+ state: input.urlParams.state,
124+ code: input.urlParams.code ?? null,
125+ error: input.urlParams.error ?? input.urlParams.error_description ?? null,
126+ })
127+ .pipe(
128+ Effect.map(
129+ (auth): OAuthPopupResult<TAuth> => ({
130+ type: OAUTH_POPUP_MESSAGE_TYPE,
131+ ok: true,
132+ sessionId: input.urlParams.state,
133+ ...auth,
134+ }),
135+ ),
136+ Effect.catchAllCause((cause) =>
137+ Effect.succeed<OAuthPopupResult<TAuth>>({
138+ type: OAUTH_POPUP_MESSAGE_TYPE,
139+ ok: false,
140+ sessionId: null,
141+ error: input.toErrorMessage(Cause.squash(cause)),
142+ }),
143+ ),
144+ Effect.map((result) => popupDocument(result, input.channelName)),
145+ );
packages/plugins/oauth2/src/popup-types.ts+30−0
@@ -0,0 +1,30 @@
1+// ---------------------------------------------------------------------------
2+// OAuth popup result — the message shape exchanged between the popup window
3+// (opened during authorization) and the opener (the onboarding UI). Both the
4+// server-side HTML generator and the client-side popup opener agree on this
5+// shape so that success / failure can be communicated reliably via both
6+// `postMessage` and `BroadcastChannel`.
7+// ---------------------------------------------------------------------------
8+
9+/** Message type literal used to identify our popup results. */
10+export const OAUTH_POPUP_MESSAGE_TYPE = "executor:oauth-result" as const;
11+
12+export type OAuthPopupResult<TAuth> =
13+ | ({
14+ readonly type: typeof OAUTH_POPUP_MESSAGE_TYPE;
15+ readonly ok: true;
16+ readonly sessionId: string;
17+ } & TAuth)
18+ | {
19+ readonly type: typeof OAUTH_POPUP_MESSAGE_TYPE;
20+ readonly ok: false;
21+ readonly sessionId: null;
22+ readonly error: string;
23+ };
24+
25+export const isOAuthPopupResult = <TAuth>(
26+ value: unknown,
27+): value is OAuthPopupResult<TAuth> =>
28+ typeof value === "object" &&
29+ value !== null &&
30+ (value as { type?: unknown }).type === OAUTH_POPUP_MESSAGE_TYPE;
packages/plugins/oauth2/src/react.test.ts+339−0
@@ -0,0 +1,339 @@
1+// ---------------------------------------------------------------------------
2+// openOAuthPopup fidelity tests. Simulates the browser environment by
3+// stubbing window.open / postMessage / BroadcastChannel so we can lock in
4+// the settle-once semantics, origin checking, and popup-blocked handling
5+// without a real DOM.
6+// ---------------------------------------------------------------------------
7+
8+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
9+
10+import {
11+ OAUTH_POPUP_MESSAGE_TYPE,
12+ openOAuthPopup,
13+ type OAuthPopupResult,
14+} from "./react";
15+
16+type TestAuth = { accessToken: string };
17+
18+type MockPopup = { closed: boolean; close: () => void };
19+
20+const originalWindow = (globalThis as { window?: unknown }).window;
21+const originalBroadcastChannel = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel;
22+
23+type MockChannel = {
24+ name: string;
25+ onmessage: ((event: { data: unknown }) => void) | null;
26+ close: () => void;
27+ closed: boolean;
28+};
29+
30+const channels: MockChannel[] = [];
31+
32+class FakeBroadcastChannel {
33+ name: string;
34+ onmessage: ((event: { data: unknown }) => void) | null = null;
35+ closed = false;
36+ constructor(name: string) {
37+ this.name = name;
38+ channels.push(this as unknown as MockChannel);
39+ }
40+ close() {
41+ this.closed = true;
42+ }
43+}
44+
45+type WindowListener = (event: { origin: string; data: unknown }) => void;
46+
47+type FakeWindow = {
48+ location: { origin: string };
49+ screenX: number;
50+ screenY: number;
51+ outerWidth: number;
52+ outerHeight: number;
53+ open: (url: string, name: string, features?: string) => MockPopup | null;
54+ addEventListener: (type: string, cb: WindowListener) => void;
55+ removeEventListener: (type: string, cb: WindowListener) => void;
56+ messageListeners: Set<WindowListener>;
57+ _openCalls: Array<{ url: string; name: string; features?: string }>;
58+ _popupBlocked: boolean;
59+ _popups: MockPopup[];
60+};
61+
62+const makeFakeWindow = (): FakeWindow => {
63+ const messageListeners = new Set<WindowListener>();
64+ const openCalls: FakeWindow["_openCalls"] = [];
65+ const popups: MockPopup[] = [];
66+ return {
67+ location: { origin: "https://app.example.com" },
68+ screenX: 0,
69+ screenY: 0,
70+ outerWidth: 1280,
71+ outerHeight: 900,
72+ open: (url, name, features) => {
73+ openCalls.push({ url, name, features });
74+ if (fakeWindow._popupBlocked) return null;
75+ const popup: MockPopup = {
76+ closed: false,
77+ close() {
78+ popup.closed = true;
79+ },
80+ };
81+ popups.push(popup);
82+ return popup;
83+ },
84+ addEventListener: (type, cb) => {
85+ if (type === "message") messageListeners.add(cb);
86+ },
87+ removeEventListener: (type, cb) => {
88+ if (type === "message") messageListeners.delete(cb);
89+ },
90+ messageListeners,
91+ _openCalls: openCalls,
92+ _popupBlocked: false,
93+ _popups: popups,
94+ };
95+};
96+
97+let fakeWindow: FakeWindow;
98+
99+beforeEach(() => {
100+ fakeWindow = makeFakeWindow();
101+ (globalThis as { window?: unknown }).window = fakeWindow;
102+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel;
103+ channels.length = 0;
104+});
105+
106+afterEach(() => {
107+ (globalThis as { window?: unknown }).window = originalWindow;
108+ (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = originalBroadcastChannel;
109+});
110+
111+const successMessage = (auth: TestAuth): OAuthPopupResult<TestAuth> => ({
112+ type: OAUTH_POPUP_MESSAGE_TYPE,
113+ ok: true,
114+ sessionId: "session-1",
115+ ...auth,
116+});
117+
118+describe("openOAuthPopup", () => {
119+ it("opens a centered popup at the given url with the given name", () => {
120+ openOAuthPopup<TestAuth>({
121+ url: "https://auth.example.com/authorize?x=1",
122+ onResult: () => {},
123+ popupName: "my-oauth-popup",
124+ channelName: "my-channel",
125+ });
126+ expect(fakeWindow._openCalls).toHaveLength(1);
127+ const call = fakeWindow._openCalls[0]!;
128+ expect(call.url).toBe("https://auth.example.com/authorize?x=1");
129+ expect(call.name).toBe("my-oauth-popup");
130+ expect(call.features).toContain("width=640");
131+ expect(call.features).toContain("height=760");
132+ expect(call.features).toContain("popup=1");
133+ });
134+
135+ it("resolves via window.message event and only accepts same-origin messages", () => {
136+ const results: Array<OAuthPopupResult<TestAuth>> = [];
137+ openOAuthPopup<TestAuth>({
138+ url: "https://auth.example.com/authorize",
139+ onResult: (data) => results.push(data),
140+ popupName: "p",
141+ channelName: "c",
142+ });
143+
144+ // Cross-origin message should be ignored.
145+ for (const listener of fakeWindow.messageListeners) {
146+ listener({ origin: "https://evil.example.com", data: successMessage({ accessToken: "nope" }) });
147+ }
148+ expect(results).toHaveLength(0);
149+
150+ // Same-origin message resolves.
151+ for (const listener of fakeWindow.messageListeners) {
152+ listener({ origin: "https://app.example.com", data: successMessage({ accessToken: "tok" }) });
153+ }
154+ expect(results).toHaveLength(1);
155+ expect(results[0]!.ok).toBe(true);
156+ });
157+
158+ it("resolves via BroadcastChannel when postMessage does not reach the opener", () => {
159+ const results: Array<OAuthPopupResult<TestAuth>> = [];
160+ openOAuthPopup<TestAuth>({
161+ url: "https://auth.example.com/authorize",
162+ onResult: (data) => results.push(data),
163+ popupName: "p",
164+ channelName: "c",
165+ });
166+ expect(channels).toHaveLength(1);
167+ channels[0]!.onmessage?.({ data: successMessage({ accessToken: "tok" }) });
168+ expect(results).toHaveLength(1);
169+ });
170+
171+ it("settles exactly once even if both channels deliver a result", () => {
172+ const results: Array<OAuthPopupResult<TestAuth>> = [];
173+ openOAuthPopup<TestAuth>({
174+ url: "https://auth.example.com/authorize",
175+ onResult: (data) => results.push(data),
176+ popupName: "p",
177+ channelName: "c",
178+ });
179+ // Deliver via BroadcastChannel first.
180+ channels[0]!.onmessage?.({ data: successMessage({ accessToken: "one" }) });
181+ // Then via postMessage.
182+ for (const listener of fakeWindow.messageListeners) {
183+ listener({ origin: "https://app.example.com", data: successMessage({ accessToken: "two" }) });
184+ }
185+ expect(results).toHaveLength(1);
186+ });
187+
188+ it("ignores messages that don't match the OAuthPopupResult shape", () => {
189+ const results: Array<OAuthPopupResult<TestAuth>> = [];
190+ openOAuthPopup<TestAuth>({
191+ url: "https://auth.example.com/authorize",
192+ onResult: (data) => results.push(data),
193+ popupName: "p",
194+ channelName: "c",
195+ });
196+ for (const listener of fakeWindow.messageListeners) {
197+ listener({ origin: "https://app.example.com", data: { type: "other", ok: true } });
198+ }
199+ expect(results).toHaveLength(0);
200+ });
201+
202+ it("closes the BroadcastChannel and removes the listener when settled", () => {
203+ openOAuthPopup<TestAuth>({
204+ url: "https://auth.example.com/authorize",
205+ onResult: () => {},
206+ popupName: "p",
207+ channelName: "c",
208+ });
209+ for (const listener of fakeWindow.messageListeners) {
210+ listener({ origin: "https://app.example.com", data: successMessage({ accessToken: "tok" }) });
211+ }
212+ expect(fakeWindow.messageListeners.size).toBe(0);
213+ expect(channels[0]!.closed).toBe(true);
214+ });
215+
216+ it("invokes onOpenFailed when the browser blocks the popup", async () => {
217+ fakeWindow._popupBlocked = true;
218+ const onOpenFailed = vi.fn();
219+ openOAuthPopup<TestAuth>({
220+ url: "https://auth.example.com/authorize",
221+ onResult: () => {},
222+ popupName: "p",
223+ channelName: "c",
224+ onOpenFailed,
225+ });
226+ // queueMicrotask is still a microtask — await a resolved promise to flush.
227+ await Promise.resolve();
228+ expect(onOpenFailed).toHaveBeenCalledOnce();
229+ });
230+
231+ it("teardown function is idempotent", () => {
232+ const teardown = openOAuthPopup<TestAuth>({
233+ url: "https://auth.example.com/authorize",
234+ onResult: () => {},
235+ popupName: "p",
236+ channelName: "c",
237+ });
238+ teardown();
239+ teardown();
240+ expect(fakeWindow.messageListeners.size).toBe(0);
241+ expect(channels[0]!.closed).toBe(true);
242+ });
243+
244+ it("accepts a custom width and height", () => {
245+ openOAuthPopup<TestAuth>({
246+ url: "https://auth.example.com/authorize",
247+ onResult: () => {},
248+ popupName: "p",
249+ channelName: "c",
250+ width: 500,
251+ height: 500,
252+ });
253+ const call = fakeWindow._openCalls[0]!;
254+ expect(call.features).toContain("width=500");
255+ expect(call.features).toContain("height=500");
256+ });
257+
258+ describe("close detection", () => {
259+ beforeAll(() => {
260+ vi.useFakeTimers();
261+ });
262+ afterAll(() => {
263+ vi.useRealTimers();
264+ });
265+
266+ it("fires onClosed when the user manually closes the popup without delivering a result", () => {
267+ const onResult = vi.fn();
268+ const onClosed = vi.fn();
269+ openOAuthPopup<TestAuth>({
270+ url: "https://auth.example.com/authorize",
271+ onResult,
272+ popupName: "p",
273+ channelName: "c",
274+ onClosed,
275+ closedPollMs: 100,
276+ });
277+ expect(fakeWindow._popups).toHaveLength(1);
278+ // Simulate the user closing the popup.
279+ fakeWindow._popups[0]!.closed = true;
280+ vi.advanceTimersByTime(150);
281+ expect(onClosed).toHaveBeenCalledOnce();
282+ expect(onResult).not.toHaveBeenCalled();
283+ });
284+
285+ it("does NOT fire onClosed when the popup closes as a side effect of delivering a result", () => {
286+ const onResult = vi.fn();
287+ const onClosed = vi.fn();
288+ openOAuthPopup<TestAuth>({
289+ url: "https://auth.example.com/authorize",
290+ onResult,
291+ popupName: "p",
292+ channelName: "c",
293+ onClosed,
294+ closedPollMs: 100,
295+ });
296+ for (const listener of fakeWindow.messageListeners) {
297+ listener({
298+ origin: "https://app.example.com",
299+ data: successMessage({ accessToken: "tok" }),
300+ });
301+ }
302+ expect(onResult).toHaveBeenCalledOnce();
303+ // The popup will close itself after posting; advance the timer and
304+ // verify we do NOT fire onClosed redundantly.
305+ fakeWindow._popups[0]!.closed = true;
306+ vi.advanceTimersByTime(500);
307+ expect(onClosed).not.toHaveBeenCalled();
308+ });
309+
310+ it("stops polling once the teardown function is invoked", () => {
311+ const onClosed = vi.fn();
312+ const teardown = openOAuthPopup<TestAuth>({
313+ url: "https://auth.example.com/authorize",
314+ onResult: () => {},
315+ popupName: "p",
316+ channelName: "c",
317+ onClosed,
318+ closedPollMs: 100,
319+ });
320+ teardown();
321+ // Simulate the popup being closed after teardown — onClosed must not fire.
322+ fakeWindow._popups[0]!.closed = true;
323+ vi.advanceTimersByTime(500);
324+ expect(onClosed).not.toHaveBeenCalled();
325+ });
326+
327+ it("teardown closes the popup window if it's still open", () => {
328+ const teardown = openOAuthPopup<TestAuth>({
329+ url: "https://auth.example.com/authorize",
330+ onResult: () => {},
331+ popupName: "p",
332+ channelName: "c",
333+ });
334+ expect(fakeWindow._popups[0]!.closed).toBe(false);
335+ teardown();
336+ expect(fakeWindow._popups[0]!.closed).toBe(true);
337+ });
338+ });
339+});
packages/plugins/oauth2/src/react.ts+145−0
@@ -0,0 +1,145 @@
1+// ---------------------------------------------------------------------------
2+// @executor/plugin-oauth2/react — browser popup opener for OAuth flows.
3+//
4+// Opens a centered popup window pointed at an authorization URL, listens
5+// for the result via `postMessage` and `BroadcastChannel` (Safari fallback),
6+// and settles exactly once. Has NO React-specific imports so it can be used
7+// from any browser context, but lives under the `/react` entry to signal
8+// it is browser-only and should not be imported from Node / worker code.
9+// ---------------------------------------------------------------------------
10+
11+import {
12+ isOAuthPopupResult as sharedIsOAuthPopupResult,
13+ type OAuthPopupResult,
14+} from "./popup-types";
15+
16+export { OAUTH_POPUP_MESSAGE_TYPE } from "./popup-types";
17+export type { OAuthPopupResult } from "./popup-types";
18+
19+export const isOAuthPopupResult = sharedIsOAuthPopupResult;
20+
21+// ---------------------------------------------------------------------------
22+// openOAuthPopup
23+// ---------------------------------------------------------------------------
24+
25+export type OpenOAuthPopupInput<TAuth> = {
26+ readonly url: string;
27+ readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
28+ /** `window.open` target name — also used to focus an existing popup. */
29+ readonly popupName: string;
30+ /** BroadcastChannel name, must match the server-side `popupDocument` channel. */
31+ readonly channelName: string;
32+ readonly onOpenFailed?: () => void;
33+ /**
34+ * Called if the user closes the popup window without completing the
35+ * flow (detected via a `popup.closed` poll). NOT called when the popup
36+ * closes itself after a successful result post — `onResult` handles
37+ * that path. Also not called if the caller invokes the teardown
38+ * function returned from this function.
39+ */
40+ readonly onClosed?: () => void;
41+ readonly width?: number;
42+ readonly height?: number;
43+ /** How often to poll `popup.closed`. Default 500ms. */
44+ readonly closedPollMs?: number;
45+};
46+
47+/**
48+ * Open a centered popup window at `url` and resolve when the popup posts
49+ * an `OAuthPopupResult` back to the opener. Returns a teardown function
50+ * that removes the listeners, stops polling, and closes the popup window.
51+ *
52+ * Settles exactly once via one of three paths:
53+ * 1. `onResult` — popup posted a message back (success or error)
54+ * 2. `onClosed` — user closed the popup without completing the flow
55+ * 3. teardown called — caller cancelled programmatically
56+ *
57+ * If the popup is blocked (`window.open` returns null), invokes
58+ * `onOpenFailed` on the next microtask and returns a no-op teardown.
59+ */
60+export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => {
61+ const w = input.width ?? 640;
62+ const h = input.height ?? 760;
63+ const left = window.screenX + (window.outerWidth - w) / 2;
64+ const top = window.screenY + (window.outerHeight - h) / 2;
65+
66+ let settled = false;
67+ let pollHandle: ReturnType<typeof setInterval> | null = null;
68+ const channel =
69+ typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(input.channelName) : null;
70+
71+ const onMessage = (event: MessageEvent) => {
72+ if (event.origin !== window.location.origin) return;
73+ handleResult(event.data);
74+ };
75+
76+ const stopPolling = () => {
77+ if (pollHandle !== null) {
78+ clearInterval(pollHandle);
79+ pollHandle = null;
80+ }
81+ };
82+
83+ /** Close the popup window if it's still open. Swallows cross-origin errors. */
84+ const closePopup = (popup: Window | null) => {
85+ if (!popup) return;
86+ try {
87+ if (!popup.closed) popup.close();
88+ } catch {
89+ // Cross-origin access can throw; safe to ignore.
90+ }
91+ };
92+
93+ const settle = () => {
94+ if (settled) return;
95+ settled = true;
96+ window.removeEventListener("message", onMessage);
97+ channel?.close();
98+ stopPolling();
99+ };
100+
101+ const handleResult = (data: unknown) => {
102+ if (!isOAuthPopupResult<TAuth>(data) || settled) return;
103+ settle();
104+ input.onResult(data);
105+ };
106+
107+ window.addEventListener("message", onMessage);
108+ if (channel) channel.onmessage = (event) => handleResult(event.data);
109+
110+ const popup = window.open(
111+ input.url,
112+ input.popupName,
113+ `width=${w},height=${h},left=${left},top=${top},popup=1`,
114+ );
115+ if (!popup) {
116+ if (!settled) {
117+ settle();
118+ queueMicrotask(() => input.onOpenFailed?.());
119+ }
120+ return () => {};
121+ }
122+
123+ // Poll for manual popup close. We only settle via onClosed if no
124+ // message-based result has arrived; onResult settles first and
125+ // stops the poll before we see the close.
126+ const pollMs = input.closedPollMs ?? 500;
127+ pollHandle = setInterval(() => {
128+ let isClosed = false;
129+ try {
130+ isClosed = popup.closed;
131+ } catch {
132+ // Cross-origin access can throw during navigation; treat as open.
133+ }
134+ if (isClosed && !settled) {
135+ settle();
136+ input.onClosed?.();
137+ }
138+ }, pollMs);
139+
140+ return () => {
141+ if (settled) return;
142+ settle();
143+ closePopup(popup);
144+ };
145+};
packages/plugins/oauth2/tsconfig.json+1−1
@@ -5,7 +5,7 @@ · 4 unmodified lines
55 "moduleResolution": "Bundler",
66 "strict": true,
77 "skipLibCheck": true,
8− "lib": ["ES2022"],
8+ "lib": ["ES2022", "DOM"],
99 "types": ["bun-types", "node"],
1010 "noUnusedLocals": true,
1111 "noImplicitOverride": true,
packages/plugins/oauth2/tsup.config.ts+2−0
@@ -3,6 +3,8 @@ import { defineConfig } from "tsup"; · 2 unmodified lines
33 export default defineConfig({
44 entry: {
55 index: "src/index.ts",
6+ http: "src/http.ts",
7+ react: "src/react.ts",
68 },
79 format: ["esm"],
810 dts: false,