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).
Unified · Split
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | 2 | import { Cause, Context, Effect } from "effect"; |
| 3 | 3 | |
| 4 | +import { runOAuthCallback } from "@executor/plugin-oauth2/http"; | |
| 5 | + | |
| 4 | 6 | import { addGroup } from "@executor/api"; |
| 5 | 7 | import type { |
| 6 | 8 | GoogleDiscoveryAddSourceInput, |
| @@ -26,55 +28,7 @@ export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscover · 19 unmodified lines | ||
| 26 | 28 | |
| 27 | 29 | const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); |
| 28 | 30 | |
| 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("&", "&") | |
| 45 | − .replaceAll("<", "<") | |
| 46 | − .replaceAll(">", ">") | |
| 47 | − .replaceAll('"', """) | |
| 48 | − .replaceAll("'", "'"); | |
| 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"; | |
| 78 | 32 | |
| 79 | 33 | const toPopupErrorMessage = (error: unknown): string => { |
| 80 | 34 | if (error instanceof GoogleDiscoveryOAuthError) { |
| @@ -156,31 +110,22 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( · 75 unmodified lines | ||
| 156 | 110 | .handle("oauthCallback", ({ urlParams }) => |
| 157 | 111 | Effect.gen(function* () { |
| 158 | 112 | 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); | |
| 184 | 129 | }).pipe(sanitizeGoogleDiscoveryFailure), |
| 185 | 130 | ), |
| 186 | 131 | ); |
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | |
| 4 | +import { | |
| 5 | + openOAuthPopup, | |
| 6 | + type OAuthPopupResult, | |
| 7 | +} from "@executor/plugin-oauth2/react"; | |
| 8 | + | |
| 4 | 9 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; |
| 5 | 10 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | 11 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; |
| @@ -373,69 +378,10 @@ type OAuthAuth = { · 366 unmodified lines | ||
| 373 | 378 | scopes: string[]; |
| 374 | 379 | }; |
| 375 | 380 | |
| 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>; | |
| 388 | 382 | |
| 389 | 383 | 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"; | |
| 439 | 385 | |
| 440 | 386 | export default function AddGoogleDiscoverySource(props: { |
| 441 | 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( | |
| 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) => { | |
| 571 | 519 | oauthCleanup.current = null; |
| 572 | 520 | setStartingOAuth(false); |
| 573 | 521 | if (result.ok) { |
| @@ -587,12 +535,12 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines | ||
| 587 | 535 | setError(result.error); |
| 588 | 536 | } |
| 589 | 537 | }, |
| 590 | − () => { | |
| 538 | + onOpenFailed: () => { | |
| 591 | 539 | oauthCleanup.current = null; |
| 592 | 540 | setStartingOAuth(false); |
| 593 | 541 | setError("OAuth popup was blocked"); |
| 594 | 542 | }, |
| 595 | − ); | |
| 543 | + }); | |
| 596 | 544 | } catch (e) { |
| 597 | 545 | setStartingOAuth(false); |
| 598 | 546 | setError(e instanceof Error ? e.message : "Failed to start OAuth"); |
| @@ -16,7 +16,9 @@ · 15 unmodified lines | ||
| 16 | 16 | ], |
| 17 | 17 | "type": "module", |
| 18 | 18 | "exports": { |
| 19 | − ".": "./src/index.ts" | |
| 19 | + ".": "./src/index.ts", | |
| 20 | + "./http": "./src/http.ts", | |
| 21 | + "./react": "./src/react.ts" | |
| 20 | 22 | }, |
| 21 | 23 | "publishConfig": { |
| 22 | 24 | "access": "public", |
| @@ -26,6 +28,18 @@ · 3 unmodified lines | ||
| 26 | 28 | "types": "./dist/index.d.ts", |
| 27 | 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 | }, |
| @@ -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 <script>alert(1)</script>"); | |
| 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"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 | +}); | |
| @@ -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("&", "&") | |
| 30 | + .replaceAll("<", "<") | |
| 31 | + .replaceAll(">", ">") | |
| 32 | + .replaceAll('"', """) | |
| 33 | + .replaceAll("'", "'"); | |
| 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 | + ); | |
| @@ -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; | |
| @@ -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 | +}); | |
| @@ -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 | +}; | |
| @@ -5,7 +5,7 @@ · 4 unmodified lines | ||
| 5 | 5 | "moduleResolution": "Bundler", |
| 6 | 6 | "strict": true, |
| 7 | 7 | "skipLibCheck": true, |
| 8 | − "lib": ["ES2022"], | |
| 8 | + "lib": ["ES2022", "DOM"], | |
| 9 | 9 | "types": ["bun-types", "node"], |
| 10 | 10 | "noUnusedLocals": true, |
| 11 | 11 | "noImplicitOverride": true, |
| @@ -3,6 +3,8 @@ import { defineConfig } from "tsup"; · 2 unmodified lines | ||
| 3 | 3 | export default defineConfig({ |
| 4 | 4 | entry: { |
| 5 | 5 | index: "src/index.ts", |
| 6 | + http: "src/http.ts", | |
| 7 | + react: "src/react.ts", | |
| 6 | 8 | }, |
| 7 | 9 | format: ["esm"], |
| 8 | 10 | dts: false, |