Rhys Sullivanharden local auth (#1012)19c26574 months agoHistory
172 lines · 6.1 KB · TypeScript
RawDownload
/** * Local single-user bearer-token handling for the web SPA. * * The local server mints one bearer token (see `@executor-js/local` auth.json) * and gates every `/api` and `/mcp` request on it. This module gets that token * into the browser with zero ceremony in the common case: * *   - Desktop: the Electron main process injects the header at the session *     layer, so the renderer never needs the token and this module no-ops. *   - Standalone web AND dev (vite): the server prints `…/?_token=<token>`. *     `bootstrapLocalAuthToken` reads it once, stores it in localStorage, strips *     it from the URL, and sets the connection's bearer auth. Subsequent loads *     read it from localStorage. Dev uses the exact same path — no dev-only *     token injection. * * When a request still 401s (cleared storage, rotated token), the API client * calls `notifyLocalAuthRequired()` and `<LocalAuthGate>` prompts for the token. */ import * as React from "react"; import { getExecutorServerConnection, setExecutorServerConnection } from "./server-connection"; const STORAGE_KEY = "executor.authToken"; const isDesktopBridge = (): boolean =>  typeof globalThis.window?.executor?.getServerConnection === "function"; const readStoredToken = (): string | null => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw (private mode / disabled storage)  try {    return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null;  } catch {    return null;  }}; const persistToken = (token: string): void => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: localStorage can throw (private mode / disabled storage)  try {    globalThis.localStorage?.setItem(STORAGE_KEY, token);  } catch {    // best-effort; the token still applies for this session  }}; const applyBearer = (token: string): void => {  const current = getExecutorServerConnection();  // Only standalone web carries the token in the client; desktop injects it.  if (current.kind !== "http") return;  setExecutorServerConnection({ ...current, auth: { kind: "bearer", token } });}; /** * Resolve and apply the local bearer token at boot. Call once before the router * mounts. Order: `?_token` URL param (one-time, persisted + stripped) → * localStorage. Identical in dev and prod. */export const bootstrapLocalAuthToken = (): void => {  if (isDesktopBridge()) return;   const url = globalThis.window ? new URL(window.location.href) : null;  const fromUrl = url?.searchParams.get("_token") ?? null;  if (fromUrl) {    persistToken(fromUrl);    url!.searchParams.delete("_token");    globalThis.window?.history?.replaceState(null, "", url!.pathname + url!.search + url!.hash);    applyBearer(fromUrl);    return;  }   const stored = readStoredToken();  if (stored) applyBearer(stored);}; /** * Persist a token entered manually (the login gate) and reload. The gate * appeared because an API call 401'd, leaving those atoms in a cached failure * state; a reload re-bootstraps the whole app with the token from the start so * every atom re-fetches with the bearer (rather than dismissing the gate in * place onto stale "failed to load" data). */export const setLocalAuthToken = (token: string): void => {  const trimmed = token.trim();  if (!trimmed) return;  persistToken(trimmed);  if (globalThis.window?.location) {    globalThis.window.location.reload();    return;  }  applyBearer(trimmed);  setAuthRequired(false);}; // --- "auth required" signal ------------------------------------------------- let authRequired = false;const listeners = new Set<() => void>(); const setAuthRequired = (value: boolean): void => {  if (authRequired === value) return;  authRequired = value;  for (const listener of listeners) listener();}; /** * Signal that a request was rejected as unauthenticated. No-op on desktop (the * main process owns the credential) — the gate is only for standalone web. */export const notifyLocalAuthRequired = (): void => {  if (isDesktopBridge()) return;  setAuthRequired(true);}; const useAuthRequired = (): boolean =>  React.useSyncExternalStore(    (onChange) => {      listeners.add(onChange);      return () => listeners.delete(onChange);    },    () => authRequired,    () => false,  ); // --- Gate UI ---------------------------------------------------------------- /** * Renders its children unless a local-auth credential is required, in which * case it shows a single-input token entry screen. Mount it around the app * shell. */export function LocalAuthGate(props: { readonly children: React.ReactNode }) {  const required = useAuthRequired();  const [value, setValue] = React.useState("");   if (!required) return <>{props.children}</>;   return (    <div className="flex h-screen items-center justify-center bg-background p-6">      <form        className="flex w-full max-w-sm flex-col gap-3 rounded-xl border border-border bg-card p-6 shadow-sm"        onSubmit={(event) => {          event.preventDefault();          setLocalAuthToken(value);        }}      >        <h1 className="text-base font-semibold text-foreground">Authentication required</h1>        <p className="text-sm text-muted-foreground">          Run <code>executor open</code> in your terminal to sign in, or paste the server's token          below.        </p>        {/* oxlint-disable-next-line react/forbid-elements -- token entry input */}        <input          type="password"          autoFocus          value={value}          onChange={(event) => setValue(event.target.value)}          placeholder="Bearer token"          className="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"        />        {/* oxlint-disable-next-line react/forbid-elements -- gate submit button */}        <button          type="submit"          disabled={value.trim().length === 0}          className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"        >          Connect        </button>      </form>    </div>  );}