Rhys Sullivanharden local auth (#1012)19c26574 months agoHistory
311 lines · 11.7 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// openOAuthPopup — browser popup opener for OAuth flows.//// Opens a centered popup window pointed at an authorization URL, listens// for the result via `postMessage` and `BroadcastChannel` (Safari fallback),// and settles exactly once. Has NO React-specific imports so it can be used// from any browser context, but lives under the `/react` entry to signal// it is browser-only and should not be imported from Node / worker code.// --------------------------------------------------------------------------- import {  isOAuthPopupResult as sharedIsOAuthPopupResult,  type OAuthPopupResult,} from "@executor-js/sdk/shared";import { getExecutorServerAuthorizationHeader } from "./server-connection"; export { OAUTH_POPUP_MESSAGE_TYPE } from "@executor-js/sdk/shared";export type { OAuthPopupResult } from "@executor-js/sdk/shared"; export const isOAuthPopupResult = sharedIsOAuthPopupResult; // ---------------------------------------------------------------------------// openOAuthPopup// --------------------------------------------------------------------------- export type OpenOAuthPopupInput<TAuth> = {  readonly url: string;  readonly onResult: (data: OAuthPopupResult<TAuth>) => void;  readonly reservedPopup?: ReservedOAuthPopup;  /** Ignore popup messages for any other in-flight OAuth session. */  readonly expectedSessionId?: string;  /** `window.open` target name — also used to focus an existing popup. */  readonly popupName: string;  /** BroadcastChannel name, must match the server-side `popupDocument` channel. */  readonly channelName: string;  readonly onOpenFailed?: () => void;  /**   * Called if the user closes the popup window without completing the   * flow (detected via a `popup.closed` poll). NOT called when the popup   * closes itself after a successful result post — `onResult` handles   * that path. Also not called if the caller invokes the teardown   * function returned from this function.   */  readonly onClosed?: () => void;  readonly width?: number;  readonly height?: number;  /** How often to poll `popup.closed`. Default 500ms. Set to null to disable. */  readonly closedPollMs?: number | null;}; export type ReservedOAuthPopup = {  readonly popup: Window;}; const isHttpPopupUrl = (value: string): boolean => {  if (!URL.canParse(value)) return false;  const url = new URL(value);  return url.protocol === "http:" || url.protocol === "https:";}; const oauthPopupFeatures = (input: { readonly width?: number; readonly height?: number }) => {  const w = input.width ?? 640;  const h = input.height ?? 760;  const left = window.screenX + (window.outerWidth - w) / 2;  const top = window.screenY + (window.outerHeight - h) / 2;  return `width=${w},height=${h},left=${left},top=${top},popup=1`;}; export const reserveOAuthPopup = (input: {  readonly popupName: string;  readonly width?: number;  readonly height?: number;}): ReservedOAuthPopup | null => {  const popup = window.open("about:blank", input.popupName, oauthPopupFeatures(input));  if (!popup) return null;  // Keep opener available for the same-origin callback page. Browser  // BroadcastChannel delivery can be partitioned in popup flows, so the  // callback's postMessage path is the primary completion signal.  return { popup };}; /** * Open a centered popup window at `url` and resolve when the popup posts * an `OAuthPopupResult` back to the opener. Returns a teardown function * that removes the listeners, stops polling, and closes the popup window. * * Settles exactly once via one of three paths: *   1. `onResult`      — popup posted a message back (success or error) *   2. `onClosed`      — user closed the popup without completing the flow, *                        unless `closedPollMs` is null *   3. teardown called — caller cancelled programmatically * * If the popup is blocked (`window.open` returns null), invokes * `onOpenFailed` on the next microtask and returns a no-op teardown. */export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() => void) => {  if (!isHttpPopupUrl(input.url)) {    queueMicrotask(() => input.onOpenFailed?.());    return () => {};  }   let settled = false;  let pollHandle: ReturnType<typeof setInterval> | null = null;  const channel =    typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(input.channelName) : null;   const onMessage = (event: MessageEvent) => {    if (event.origin !== window.location.origin) return;    handleResult(event.data);  };   // localStorage `storage` events are the reliable same-origin completion path:  // they fire on the opener when the (same-origin) callback page writes, survive  // the provider's COOP severing `window.opener`, and aren't lost to the popup's  // auto-close (unlike a raced BroadcastChannel). The callback writes the result  // under `channelName`; we read it, clean up, and settle.  const onStorage = (event: StorageEvent) => {    if (event.key !== input.channelName || event.newValue === null) return;    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.parse of a foreign storage value can throw    try {      // oxlint-disable-next-line executor/no-json-parse -- boundary: browser-only helper, no Effect runtime; parsing a same-origin localStorage signal      const data: unknown = JSON.parse(event.newValue);      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw (private mode / disabled)      try {        window.localStorage.removeItem(input.channelName);      } catch {        // best-effort cleanup      }      handleResult(data);    } catch {      // Malformed value — ignore; another channel may still deliver.    }  };   const stopPolling = () => {    if (pollHandle !== null) {      clearInterval(pollHandle);      pollHandle = null;    }  };   /** Close the popup window if it's still open. Swallows cross-origin errors. */  const closePopup = (popup: Window | null) => {    if (!popup) return;    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: cross-origin popup state can throw and cleanup is best-effort    try {      if (!popup.closed) popup.close();    } catch {      // Cross-origin access can throw; safe to ignore.    }  };   const settle = () => {    if (settled) return;    settled = true;    window.removeEventListener("message", onMessage);    window.removeEventListener("storage", onStorage);    channel?.close();    stopPolling();  };   const handleResult = (data: unknown) => {    if (!isOAuthPopupResult<TAuth>(data) || settled) return;    if (input.expectedSessionId && data.sessionId !== input.expectedSessionId) return;    settle();    input.onResult(data);  };   window.addEventListener("message", onMessage);  window.addEventListener("storage", onStorage);  if (channel) channel.onmessage = (event) => handleResult(event.data);   const popup =    input.reservedPopup?.popup ??    reserveOAuthPopup({      popupName: input.popupName,      width: input.width,      height: input.height,    })?.popup ??    null;  if (!popup) {    if (!settled) {      settle();      queueMicrotask(() => input.onOpenFailed?.());    }    return () => {};  }  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: popup navigation can fail if the browser has invalidated the handle  try {    popup.location.href = input.url;  } catch {    if (!settled) {      settle();      queueMicrotask(() => input.onOpenFailed?.());    }    return () => {};  }   // Some providers use COOP headers that can make a live cross-origin  // popup look closed to the opener. Callers can disable polling and rely  // on the explicit cancel path plus BroadcastChannel completion.  const pollMs = input.closedPollMs === undefined ? 500 : input.closedPollMs;  if (pollMs !== null) {    pollHandle = setInterval(() => {      let isClosed = false;      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser popup.closed can throw while navigating cross-origin      try {        isClosed = popup.closed;      } catch {        // Cross-origin access can throw during navigation; treat as open.      }      if (isClosed && !settled) {        settle();        input.onClosed?.();      }    }, pollMs);  }   return () => {    if (settled) return;    settle();    closePopup(popup);  };}; // ---------------------------------------------------------------------------// System-browser flow — used when a desktop host (Electron) opens the auth// URL in the user's real browser. There's no shared origin, so the// renderer polls `/api/oauth/await/:sessionId` for the result. The local// server publishes there via `setOAuthCompletionListener` (see// apps/local/src/serve.ts).// --------------------------------------------------------------------------- export type OpenOAuthSystemBrowserInput<TAuth> = {  readonly url: string;  readonly sessionId: string;  readonly openExternal: (url: string) => Promise<void>;  readonly onResult: (data: OAuthPopupResult<TAuth>) => void;  /** Called once if the external open itself fails (URL rejected, IPC error). */  readonly onOpenFailed?: (cause: unknown) => void;  /** Poll cadence. Default 1000ms. */  readonly pollMs?: number;  /** Stop polling after this many ms with no result. Default 10 minutes. */  readonly timeoutMs?: number;  readonly onTimeout?: () => void;}; const OAUTH_AWAIT_DEFAULT_POLL_MS = 1000;const OAUTH_AWAIT_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; export const openOAuthSystemBrowser = <TAuth>(  input: OpenOAuthSystemBrowserInput<TAuth>,): (() => void) => {  let settled = false;  let pollHandle: ReturnType<typeof setInterval> | null = null;  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;  const controller = new AbortController();   const settle = () => {    if (settled) return;    settled = true;    if (pollHandle !== null) clearInterval(pollHandle);    if (timeoutHandle !== null) clearTimeout(timeoutHandle);    controller.abort();  };   const poll = async () => {    if (settled) return;    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch can reject for transient network errors during polling    try {      // The await poll is now gated like the rest of /api — carry the bearer      // (standalone web). On desktop the connection has no client-side auth and      // the main process injects the header instead.      const authorization = getExecutorServerAuthorizationHeader();      const response = await fetch(`/api/oauth/await/${encodeURIComponent(input.sessionId)}`, {        signal: controller.signal,        cache: "no-store",        ...(authorization ? { headers: { authorization } } : {}),      });      if (!response.ok) return;      const body = (await response.json()) as unknown;      if (body === null || settled) return;      if (!isOAuthPopupResult<TAuth>(body)) return;      settle();      input.onResult(body);    } catch {      // Transient — next tick will retry. AbortError after settle is also caught here.    }  };   void (async () => {    // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: openExternal is host-provided IPC, no Effect runtime in this browser-only helper    try {      await input.openExternal(input.url);    } catch (cause: unknown) {      if (settled) return;      settle();      input.onOpenFailed?.(cause);    }  })();   pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS);  void poll();  timeoutHandle = setTimeout(() => {    if (settled) return;    settle();    input.onTimeout?.();  }, input.timeoutMs ?? OAUTH_AWAIT_DEFAULT_TIMEOUT_MS);   return () => settle();};