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 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | 1 | import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; |
| 2 | import { Cause, Context, Effect } from "effect"; | 2 | import { Cause, Context, Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import { runOAuthCallback } from "@executor/plugin-oauth2/http"; | ||
| 5 | |||
| 4 | import { addGroup } from "@executor/api"; | 6 | import { addGroup } from "@executor/api"; |
| 5 | import type { | 7 | import type { |
| 6 | GoogleDiscoveryAddSourceInput, | 8 | GoogleDiscoveryAddSourceInput, |
| @@ -26,55 +28,7 @@ export class GoogleDiscoveryExtensionService extends Context.Tag("GoogleDiscover · 19 unmodified lines | |||
| 26 | 28 | ||
| 27 | const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); | 29 | const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); |
| 28 | 30 | ||
| 29 | type OAuthPopupResult = | 31 | const 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 | |||
| 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 | }; | ||
| 78 | 32 | ||
| 79 | const toPopupErrorMessage = (error: unknown): string => { | 33 | const 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* ext | 113 | 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 | ); |
| @@ -1,6 +1,11 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 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 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; | 9 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; |
| 5 | import { useScope } from "@executor/react/api/scope-context"; | 10 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; | 11 | import { 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 | ||
| 376 | type OAuthPopupResult = | 381 | type 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 | ||
| 389 | const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result"; | 383 | const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result"; |
| 390 | 384 | const OAUTH_POPUP_NAME = "google-discovery-oauth"; | |
| 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 | } | ||
| 439 | 385 | ||
| 440 | export default function AddGoogleDiscoverySource(props: { | 386 | export 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"); |
| @@ -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 | }, |
| @@ -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 | "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, |
| @@ -3,6 +3,8 @@ import { defineConfig } from "tsup"; · 2 unmodified lines | |||
| 3 | export default defineConfig({ | 3 | export 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, |