/** * Client-side auth token management. * * The web server generates a random bearer token at launch and passes it to * the browser via the URL fragment (e.g. `http://127.0.0.1:3000/#token=`). * Fragments are never sent in HTTP requests or logged by servers/proxies, * keeping the token local to the machine. * * On first load this module extracts the token from the fragment, persists * it to localStorage (so it survives page refreshes and is accessible from * all tabs on the same origin), and clears the fragment from the address bar. * All subsequent API calls attach the token via the `Authorization: Bearer` * header. * * localStorage is shared across all tabs on the same origin. Because each * SF instance binds to a unique random port, the origin already scopes * the token to that instance — no additional namespacing is needed. * * For EventSource (SSE), which cannot send custom headers, the token is * appended as a `?_token=` query parameter instead. */ const AUTH_STORAGE_KEY = "sf-auth-token"; let cachedToken: string | null = null; /** * Extract the auth token from the URL fragment on first call, then return * the cached value. Falls back to localStorage so the token survives * page refreshes and is available to all tabs on the same origin. * Clears the fragment from the address bar after extraction. */ export function getAuthToken(): string | null { if (cachedToken !== null) return cachedToken; if (typeof window === "undefined") return null; // 1. Try the URL fragment (initial page load from sf --web) const hash = window.location.hash; if (hash) { const match = hash.match(/token=([a-fA-F0-9]+)/); if (match) { cachedToken = match[1]; // Persist to localStorage so the token survives page refreshes and // is available to other tabs on the same origin (same SF instance). try { localStorage.setItem(AUTH_STORAGE_KEY, cachedToken); } catch { // Storage unavailable (e.g. private browsing quota exceeded) — the // in-memory cache still works for the current page lifecycle. } // Clear the fragment so the token isn't visible in the address bar // or leaked via the Referer header on external navigations. window.history.replaceState( null, "", window.location.pathname + window.location.search, ); return cachedToken; } } // 2. Fall back to localStorage (page refresh, second tab, bookmark without hash) try { const stored = localStorage.getItem(AUTH_STORAGE_KEY); if (stored) { cachedToken = stored; return cachedToken; } } catch { // Storage unavailable — fall through to null } return null; } /** * Listen for token changes from other tabs via the `storage` event. * When another tab writes a new token to localStorage, this tab picks * it up immediately without requiring a page refresh. */ if (typeof window !== "undefined") { window.addEventListener("storage", (event) => { if (event.key === AUTH_STORAGE_KEY && event.newValue) { cachedToken = event.newValue; } }); } /** * Returns an object with the `Authorization` header for use with `fetch()`. * Merges with any additional headers provided. */ export function authHeaders( extra?: Record, ): Record { const token = getAuthToken(); const headers: Record = { ...extra }; if (token) { headers["Authorization"] = `Bearer ${token}`; } return headers; } /** * Wrapper around `fetch()` that automatically injects the auth token. * * When no token is available (missing `#token=` fragment and no localStorage * entry), returns a synthetic 401 Response instead of making an unauthenticated * request that will fail server-side anyway. This lets callers handle the * missing-token case uniformly rather than silently cascading 401s. */ export async function authFetch( input: RequestInfo | URL, init?: RequestInit, ): Promise { const token = getAuthToken(); if (!token) { return new Response(JSON.stringify({ error: "No auth token available" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } const headers = new Headers(init?.headers); if (!headers.has("Authorization")) { headers.set("Authorization", `Bearer ${token}`); } return fetch(input, { ...init, headers }); } /** * Append the auth token as a `_token` query parameter to a URL string. * Used for EventSource connections which cannot send custom headers. */ export function appendAuthParam(url: string): string { const token = getAuthToken(); if (!token) return url; const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}_token=${token}`; }