Files
fusion/packages/dashboard/app/auth.ts
Fusion 9e04467b57 feat(FN-3406): add shell context hook plumbing for dashboard
Adds a new `useShellContext` hook with tests to the dashboard, integrated into `App.tsx` and `Header.tsx` to provide shell context plumbing throughout the UI.

Fusion-Task-Id: FN-3406
2026-05-07 23:21:05 -07:00

294 lines
9.5 KiB
TypeScript

/**
* Dashboard authentication: token capture, storage, and injection.
*
* Flow:
* 1. On first load, if `?token=<value>` is present in the URL, capture it,
* store it in localStorage, and strip it from the visible URL so the
* secret doesn't end up in browser history or shared screenshots.
* 2. `getAuthToken()` returns the stored token (or undefined if none).
* 3. `installAuthFetch()` wraps `window.fetch` to inject
* `Authorization: Bearer <token>` on same-origin `/api/*` calls.
* 4. `appendTokenQuery()` is for browser transports that cannot set headers
* (`EventSource`, `WebSocket`, popup navigation, `sendBeacon`, links/imgs).
*
* Safe token propagation rules:
* - Header-based requests should prefer `Authorization` (via fetch wrapper or
* `withTokenHeader`). `withTokenHeader` is idempotent and never overwrites
* an explicitly supplied `Authorization` value.
* - Query-token fallback (`fn_token`) is only appended for dashboard-owned
* URLs (same host/port and same protocol family, including http↔ws and
* https↔wss pairs). Cross-origin URLs are returned unchanged so daemon
* tokens are never leaked to third-party providers.
*
* If no token is configured (dashboard started with `--no-auth`), all helpers
* become no-ops.
*/
const STORAGE_KEY = "fn.authToken";
export const URL_TOKEN_PARAM = "token";
/** Query param name used when we can't set an Authorization header (EventSource, WebSocket). */
export const QUERY_TOKEN_PARAM = "fn_token";
let cachedToken: string | undefined;
let captureAttempted = false;
let daemonAuthFailureSignaled = false;
/**
* Browser event fired when the dashboard API returns the daemon-auth 401 payload,
* indicating the current browser token is missing/invalid and user recovery is required.
*/
export const AUTH_TOKEN_RECOVERY_REQUIRED_EVENT = "fn:auth-token-recovery-required";
interface DaemonUnauthorizedPayload {
error?: unknown;
message?: unknown;
}
function readStoredToken(): string | undefined {
try {
const value = window.localStorage.getItem(STORAGE_KEY);
return value && value.length > 0 ? value : undefined;
} catch {
return undefined;
}
}
function writeStoredToken(token: string): void {
try {
window.localStorage.setItem(STORAGE_KEY, token);
} catch {
// Private mode / storage disabled — fall through; token stays in memory.
}
}
/**
* Read the `?token=...` param off the current URL (if present) and stash it
* into localStorage, then remove it from the visible URL so the secret is not
* retained in browser history. Returns the token if one was captured.
*
* Safe to call multiple times — only the first call does work.
*/
function captureTokenFromUrl(): string | undefined {
if (captureAttempted || typeof window === "undefined") {
return undefined;
}
captureAttempted = true;
try {
const url = new URL(window.location.href);
const token = url.searchParams.get(URL_TOKEN_PARAM);
if (!token) {
return undefined;
}
writeStoredToken(token);
url.searchParams.delete(URL_TOKEN_PARAM);
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
window.history.replaceState(window.history.state, "", cleaned);
return token;
} catch {
return undefined;
}
}
/** Return the bearer token in effect for this session, if any. */
export function getAuthToken(): string | undefined {
if (cachedToken !== undefined) {
return cachedToken;
}
const captured = captureTokenFromUrl();
if (captured) {
cachedToken = captured;
return captured;
}
const stored = readStoredToken();
if (stored) {
cachedToken = stored;
return stored;
}
return undefined;
}
/** Persist a token for future dashboard API requests in this browser session. */
export function setAuthToken(token: string): void {
cachedToken = token;
daemonAuthFailureSignaled = false;
writeStoredToken(token);
}
/** Clear the stored token (e.g., on a 401 response). */
export function clearAuthToken(): void {
cachedToken = undefined;
daemonAuthFailureSignaled = false;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore — worst case, a stale token sits in memory until reload.
}
}
function normalizeProtocol(protocol: string): string {
if (protocol === "ws:") return "http:";
if (protocol === "wss:") return "https:";
return protocol;
}
function isSupportedTransportProtocol(protocol: string): boolean {
return protocol === "http:" || protocol === "https:" || protocol === "ws:" || protocol === "wss:";
}
function isDashboardOwnedUrl(url: URL): boolean {
if (typeof window === "undefined") return false;
const current = new URL(window.location.origin);
if (!isSupportedTransportProtocol(url.protocol)) {
return false;
}
const sameHost = url.hostname === current.hostname;
const samePort = url.port === current.port;
const sameProtocolFamily = normalizeProtocol(url.protocol) === normalizeProtocol(current.protocol);
return sameHost && samePort && sameProtocolFamily;
}
function parseTokenizableUrl(url: string): { parsed: URL; preserveRelativePath: boolean } | undefined {
if (typeof window === "undefined") return undefined;
try {
const parsed = new URL(url, window.location.origin);
const preserveRelativePath = url.startsWith("/");
return { parsed, preserveRelativePath };
} catch {
return undefined;
}
}
/**
* Append `fn_token=<token>` for non-header browser transports.
*
* This is intentionally restricted to dashboard-owned URLs to prevent leaking
* daemon auth secrets to third-party providers (for example OAuth hosts).
*/
export function appendTokenQuery(url: string): string {
const token = getAuthToken();
if (!token || typeof window === "undefined") {
return url;
}
const parsed = parseTokenizableUrl(url);
if (!parsed || !isDashboardOwnedUrl(parsed.parsed)) {
return url;
}
parsed.parsed.searchParams.set(QUERY_TOKEN_PARAM, token);
return parsed.preserveRelativePath
? `${parsed.parsed.pathname}${parsed.parsed.search}${parsed.parsed.hash}`
: parsed.parsed.toString();
}
/** Merge an Authorization header onto an existing HeadersInit, if we have a token. */
export function withTokenHeader(init?: HeadersInit): HeadersInit | undefined {
const token = getAuthToken();
if (!token) {
return init;
}
const headers = new Headers(init ?? {});
if (!headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
return headers;
}
function isDaemonAuthUnauthorizedPayload(payload: unknown): boolean {
if (!payload || typeof payload !== "object") {
return false;
}
const candidate = payload as DaemonUnauthorizedPayload;
return candidate.error === "Unauthorized" && candidate.message === "Valid bearer token required";
}
function emitDaemonAuthRecoverySignal(): void {
if (typeof window === "undefined" || daemonAuthFailureSignaled) {
return;
}
daemonAuthFailureSignaled = true;
window.dispatchEvent(new CustomEvent(AUTH_TOKEN_RECOVERY_REQUIRED_EVENT));
}
async function detectDaemonAuthFailure(response: Response): Promise<void> {
if (response.status !== 401) {
return;
}
try {
const responseClone = response.clone();
const contentType = responseClone.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("application/json")) {
return;
}
const payload = await responseClone.json();
if (isDaemonAuthUnauthorizedPayload(payload)) {
emitDaemonAuthRecoverySignal();
}
} catch {
// If body parsing fails, leave response untouched for callers.
}
}
/**
* Monkey-patch `window.fetch` once so every same-origin `/api/*` request gets
* a bearer token. This covers direct `fetch()` callers that don't route
* through the `api()` helper without requiring us to touch each one.
*/
export function installAuthFetch(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- property sentinel on global window object, no better type exists
if (typeof window === "undefined" || (window as any).__fnAuthFetchInstalled) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- property sentinel on global window object, no better type exists
(window as any).__fnAuthFetchInstalled = true;
// Ensure token is captured-from-URL before the first fetch fires.
getAuthToken();
const originalFetch = window.fetch.bind(window);
window.fetch = function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const token = getAuthToken();
const urlString = typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
// Only attach the token and watch for daemon auth failures on same-origin /api/* requests.
const isApiCall = (() => {
try {
const resolved = new URL(urlString, window.location.origin);
if (resolved.origin !== window.location.origin) return false;
return resolved.pathname.startsWith("/api/") || resolved.pathname === "/api";
} catch {
return urlString.startsWith("/api/") || urlString === "/api";
}
})();
if (!isApiCall) {
return originalFetch(input, init);
}
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
return originalFetch(input, { ...init, headers }).then((response) => {
void detectDaemonAuthFailure(response);
return response;
});
};
}