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 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Cause, Context, Effect } from "effect";2import { Cause, Context, Effect } from "effect";
3 3
4import { runOAuthCallback } from "@executor/plugin-oauth2/http";
5
4import { addGroup } from "@executor/api";6import { addGroup } from "@executor/api";
5import type {7import type {
6 GoogleDiscoveryAddSourceInput,8 GoogleDiscoveryAddSourceInput,
@@ -26,55 +28,7 @@ export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscover · 19 unmodified lines
26 28
27const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);29const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
28 30
29type OAuthPopupResult =31const GOOGLE_DISCOVERY_OAUTH_CHANNEL = "executor:google-discovery-oauth-result";
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
42const escapeHtml = (value: string): string =>
43 value
44 .replaceAll("&", "&amp;")
45 .replaceAll("<", "&lt;")
46 .replaceAll(">", "&gt;")
47 .replaceAll('"', "&quot;")
48 .replaceAll("'", "&#39;");
49
50const 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};
78 32
79const toPopupErrorMessage = (error: unknown): string => {33const toPopupErrorMessage = (error: unknown): string => {
80 if (error instanceof GoogleDiscoveryOAuthError) {34 if (error instanceof GoogleDiscoveryOAuthError) {
@@ -156,31 +110,22 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 75 unmodified lines
156 .handle("oauthCallback", ({ urlParams }) =>110 .handle("oauthCallback", ({ urlParams }) =>
157 Effect.gen(function* () {111 Effect.gen(function* () {
158 const ext = yield* GoogleDiscoveryExtensionService;112 const ext = yield* GoogleDiscoveryExtensionService;
159 const result = yield* ext113 const html = yield* runOAuthCallback<
160 .completeOAuth({114 GoogleDiscoveryOAuthAuthResult,
161 state: urlParams.state,115 GoogleDiscoveryOAuthError,
162 code: urlParams.code,116 never
163 error: urlParams.error ?? urlParams.error_description,117 >({
164 })118 complete: ({ state, code, error }) =>
165 .pipe(119 ext.completeOAuth({
166 Effect.map(120 state,
167 (auth): OAuthPopupResult => ({121 code: code ?? undefined,
168 type: "executor:oauth-result",122 error: error ?? undefined,
169 ok: true,123 }),
170 sessionId: urlParams.state,124 urlParams,
171 ...auth,125 toErrorMessage: toPopupErrorMessage,
172 }),126 channelName: GOOGLE_DISCOVERY_OAUTH_CHANNEL,
173 ),127 });
174 Effect.catchAllCause((cause) =>128 return yield* HttpServerResponse.html(html);
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));
184 }).pipe(sanitizeGoogleDiscoveryFailure),129 }).pipe(sanitizeGoogleDiscoveryFailure),
185 ),130 ),
186);131);
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+14−66
@@ -1,6 +1,11 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3 3
4import {
5 openOAuthPopup,
6 type OAuthPopupResult,
7} from "@executor/plugin-oauth2/react";
8
4import { secretsAtom, setSecret } from "@executor/react/api/atoms";9import { secretsAtom, setSecret } from "@executor/react/api/atoms";
5import { useScope } from "@executor/react/api/scope-context";10import { useScope } from "@executor/react/api/scope-context";
6import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";11import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";
@@ -373,69 +378,10 @@ type OAuthAuth = { · 366 unmodified lines
373 scopes: string[];378 scopes: string[];
374};379};
375 380
376type OAuthPopupResult =381type GoogleOAuthPopupResult = OAuthPopupResult<OAuthAuth>;
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 };
388 382
389const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result";383const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result";
390 384const OAUTH_POPUP_NAME = "google-discovery-oauth";
391const isOAuthPopupResult = (value: unknown): value is OAuthPopupResult =>
392 typeof value === "object" &&
393 value !== null &&
394 (value as { type?: unknown }).type === "executor:oauth-result";
395
396function 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}
439 385
440export default function AddGoogleDiscoverySource(props: {386export default function AddGoogleDiscoverySource(props: {
441 readonly onComplete: () => void;387 readonly onComplete: () => void;
@@ -565,9 +511,11 @@ export default function AddGoogleDiscoverySource(props: { · 123 unmodified lines
565 },511 },
566 });512 });
567 513
568 oauthCleanup.current = openOAuthPopup(514 oauthCleanup.current = openOAuthPopup<OAuthAuth>({
569 response.authorizationUrl,515 url: response.authorizationUrl,
570 (result) => {516 popupName: OAUTH_POPUP_NAME,
517 channelName: OAUTH_RESULT_CHANNEL,
518 onResult: (result: GoogleOAuthPopupResult) => {
571 oauthCleanup.current = null;519 oauthCleanup.current = null;
572 setStartingOAuth(false);520 setStartingOAuth(false);
573 if (result.ok) {521 if (result.ok) {
@@ -587,12 +535,12 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines
587 setError(result.error);535 setError(result.error);
588 }536 }
589 },537 },
590 () => {538 onOpenFailed: () => {
591 oauthCleanup.current = null;539 oauthCleanup.current = null;
592 setStartingOAuth(false);540 setStartingOAuth(false);
593 setError("OAuth popup was blocked");541 setError("OAuth popup was blocked");
594 },542 },
595 );543 });
596 } catch (e) {544 } catch (e) {
597 setStartingOAuth(false);545 setStartingOAuth(false);
598 setError(e instanceof Error ? e.message : "Failed to start OAuth");546 setError(e instanceof Error ? e.message : "Failed to start OAuth");
packages/plugins/oauth2/package.json+15−1
@@ -16,7 +16,9 @@ · 15 unmodified lines
16 ],16 ],
17 "type": "module",17 "type": "module",
18 "exports": {18 "exports": {
19 ".": "./src/index.ts"19 ".": "./src/index.ts",
20 "./http": "./src/http.ts",
21 "./react": "./src/react.ts"
20 },22 },
21 "publishConfig": {23 "publishConfig": {
22 "access": "public",24 "access": "public",
@@ -26,6 +28,18 @@ · 3 unmodified lines
26 "types": "./dist/index.d.ts",28 "types": "./dist/index.d.ts",
27 "default": "./dist/index.js"29 "default": "./dist/index.js"
28 }30 }
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 }
29 }43 }
30 }44 }
31 },45 },
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
8import { describe, expect, it } from "vitest";
9import { Effect } from "effect";
10
11import {
12 OAUTH_POPUP_MESSAGE_TYPE,
13 popupDocument,
14 runOAuthCallback,
15 type OAuthPopupResult,
16} from "./http";
17
18type GoogleAuth = {
19 kind: "oauth2";
20 accessTokenSecretId: string;
21 refreshTokenSecretId: string | null;
22};
23
24// ---------------------------------------------------------------------------
25// popupDocument
26// ---------------------------------------------------------------------------
27
28describe("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
101describe("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
13import { Cause, Effect } from "effect";
14
15import {
16 OAUTH_POPUP_MESSAGE_TYPE,
17 type OAuthPopupResult,
18} from "./popup-types";
19
20export { OAUTH_POPUP_MESSAGE_TYPE, isOAuthPopupResult } from "./popup-types";
21export type { OAuthPopupResult } from "./popup-types";
22
23// ---------------------------------------------------------------------------
24// HTML generation
25// ---------------------------------------------------------------------------
26
27const 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 */
40const 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 */
53export 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
90export type OAuthCallbackUrlParams = {
91 readonly state: string;
92 readonly code?: string | null;
93 readonly error?: string | null;
94 readonly error_description?: string | null;
95};
96
97export 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 */
118export 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. */
10export const OAUTH_POPUP_MESSAGE_TYPE = "executor:oauth-result" as const;
11
12export 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
25export 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
8import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
9
10import {
11 OAUTH_POPUP_MESSAGE_TYPE,
12 openOAuthPopup,
13 type OAuthPopupResult,
14} from "./react";
15
16type TestAuth = { accessToken: string };
17
18type MockPopup = { closed: boolean; close: () => void };
19
20const originalWindow = (globalThis as { window?: unknown }).window;
21const originalBroadcastChannel = (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel;
22
23type MockChannel = {
24 name: string;
25 onmessage: ((event: { data: unknown }) => void) | null;
26 close: () => void;
27 closed: boolean;
28};
29
30const channels: MockChannel[] = [];
31
32class 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
45type WindowListener = (event: { origin: string; data: unknown }) => void;
46
47type 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
62const 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
97let fakeWindow: FakeWindow;
98
99beforeEach(() => {
100 fakeWindow = makeFakeWindow();
101 (globalThis as { window?: unknown }).window = fakeWindow;
102 (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = FakeBroadcastChannel;
103 channels.length = 0;
104});
105
106afterEach(() => {
107 (globalThis as { window?: unknown }).window = originalWindow;
108 (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel = originalBroadcastChannel;
109});
110
111const successMessage = (auth: TestAuth): OAuthPopupResult<TestAuth> => ({
112 type: OAUTH_POPUP_MESSAGE_TYPE,
113 ok: true,
114 sessionId: "session-1",
115 ...auth,
116});
117
118describe("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
11import {
12 isOAuthPopupResult as sharedIsOAuthPopupResult,
13 type OAuthPopupResult,
14} from "./popup-types";
15
16export { OAUTH_POPUP_MESSAGE_TYPE } from "./popup-types";
17export type { OAuthPopupResult } from "./popup-types";
18
19export const isOAuthPopupResult = sharedIsOAuthPopupResult;
20
21// ---------------------------------------------------------------------------
22// openOAuthPopup
23// ---------------------------------------------------------------------------
24
25export 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 */
60export 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
5 "moduleResolution": "Bundler",5 "moduleResolution": "Bundler",
6 "strict": true,6 "strict": true,
7 "skipLibCheck": true,7 "skipLibCheck": true,
8 "lib": ["ES2022"],8 "lib": ["ES2022", "DOM"],
9 "types": ["bun-types", "node"],9 "types": ["bun-types", "node"],
10 "noUnusedLocals": true,10 "noUnusedLocals": true,
11 "noImplicitOverride": true,11 "noImplicitOverride": true,
packages/plugins/oauth2/tsup.config.ts+2−0
@@ -3,6 +3,8 @@ import { defineConfig } from "tsup"; · 2 unmodified lines
3export default defineConfig({3export default defineConfig({
4 entry: {4 entry: {
5 index: "src/index.ts",5 index: "src/index.ts",
6 http: "src/http.ts",
7 react: "src/react.ts",
6 },8 },
7 format: ["esm"],9 format: ["esm"],
8 dts: false,10 dts: false,