Files
fusion/packages/dashboard/app/auth.ts
gsxdsm c0f9260998 feat(dashboard): bearer-token auth with browser persistence + MIT license
Pre-release polish. Two related changes bundled because they both land the
project on public-release footing:

Dashboard auth
- fn dashboard now gates the HTTP API + terminal/badge WebSockets behind a
  bearer token by default. Token resolution order: --token flag,
  FUSION_DASHBOARD_TOKEN env, FUSION_DAEMON_TOKEN env (back-compat), or an
  auto-generated fn_<32 hex>. --no-auth disables. The startup banner prints
  a click-to-open URL with ?token=<token> embedded.
- Auth middleware now also accepts fn_token=<token> as a query-string
  fallback so EventSource and WebSocket clients (which can't set custom
  headers) still authenticate.
- setupTerminalWebSocket / setupBadgeWebSocket now refuse unauthenticated
  upgrades with a proper 401 + socket close.
- Frontend: new auth.ts module captures ?token= off the URL into
  localStorage (key fn.authToken), strips it from the visible URL via
  replaceState, and installs a window.fetch wrapper that injects
  Authorization: Bearer <token> on every same-origin /api/* request.
  EventSource/WebSocket URL builders (api.ts, sse-bus.ts, useTerminal,
  useBadgeWebSocket) route through appendTokenQuery().

MIT license
- LICENSE file at repo root.
- license: "MIT" on root package.json and every packages/*/package.json,
  plus description/bugs metadata on the CLI package.

Docs
- docs/cli-reference.md documents --token / --no-auth / FUSION_DASHBOARD_TOKEN
  and the click-to-open auth flow.
- docs/getting-started.md, docs/docker.md, README.md point at the new flow
  and the CLI reference section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 20:12:00 -07:00

189 lines
6.0 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 every same-origin `/api/*` call,
* and rewrites EventSource-style URLs by appending `fn_token=<token>`
* (EventSource can't set headers).
* 4. `appendTokenQuery()` and `withTokenHeader()` are helpers for places
* that construct URLs directly (WebSocket upgrades, EventSource).
*
* If no token is configured (dashboard started with `--no-auth`), all of the
* above no-ops — the fetch wrapper adds nothing and `appendTokenQuery` is
* identity.
*/
const STORAGE_KEY = "fn.authToken";
const URL_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;
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_PARAM);
if (!token) {
return undefined;
}
writeStoredToken(token);
url.searchParams.delete(URL_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;
}
/** Clear the stored token (e.g., on a 401 response). */
export function clearAuthToken(): void {
cachedToken = undefined;
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore — worst case, a stale token sits in memory until reload.
}
}
/** Append `fn_token=<token>` to a URL so EventSource / WebSocket can auth. */
export function appendTokenQuery(url: string): string {
const token = getAuthToken();
if (!token) {
return url;
}
try {
// Support both absolute and relative URLs by using a dummy base.
const base = url.startsWith("/") || !/^[a-z]+:\/\//i.test(url)
? new URL(url, window.location.origin)
: new URL(url);
base.searchParams.set(QUERY_TOKEN_PARAM, token);
// Preserve the original form (relative vs absolute).
return url.startsWith("/")
? base.pathname + base.search + base.hash
: base.toString();
} catch {
// URL too malformed to parse — fall back to naive concatenation.
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}${QUERY_TOKEN_PARAM}=${encodeURIComponent(token)}`;
}
}
/** 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;
}
/**
* 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 {
if (typeof window === "undefined" || (window as any).__fnAuthFetchInstalled) {
return;
}
(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();
if (!token) {
return originalFetch(input, init);
}
const urlString = typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
// Only attach the token for 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 (!headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`);
}
return originalFetch(input, { ...init, headers });
};
}