Files
fusion/packages/dashboard/src/auth-middleware.ts
2026-06-04 23:39:07 -07:00

189 lines
6.2 KiB
TypeScript

/**
* Bearer token authentication middleware for daemon mode.
*
* Provides secure constant-time token validation to protect API endpoints
* while allowing unauthenticated access to health checks.
*/
import { timingSafeEqual } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import type { IncomingMessage } from "node:http";
/**
* Query-string fallback used when the client can't set an Authorization
* header (EventSource, WebSocket handshake). The token flows as
* `?fn_token=<token>` on those URLs.
*/
export const TOKEN_QUERY_PARAM = "fn_token";
/**
* Paths exempt from the daemon bearer-token middleware.
*
* - `/api/health` — liveness probes.
* - `/api/cli-agent/hooks` — the CLI-agent hook ingestion route (U17). Hook
* scripts run inside the spawned CLI process and only hold the per-session hook
* token, NOT the daemon bearer token. That route does its OWN authentication:
* it validates the per-session token against the engine-held registry
* (constant-time) and rejects browser-context requests (Origin/Host CSRF
* defense). It must therefore bypass the daemon-token gate, not weaken it.
*/
const EXEMPT_PATHS = ["/api/health", "/api/cli-agent/hooks"];
/**
* Only /api/* paths are gated by this middleware. The SPA shell (index.html,
* /assets/*, favicon, etc.) must load unauthenticated so the frontend JS can
* run, read ?token= off the URL, and start injecting Bearer headers on API
* calls. Without this exemption the browser gets 401 on the very first GET /
* and never gets a chance to capture the token.
*/
function isApiPath(path: string): boolean {
return path === "/api" || path.startsWith("/api/");
}
/**
* Check if daemon auth should be active.
* Auth is enabled when FUSION_DAEMON_TOKEN env var is set OR daemon options are provided.
* Always returns false when options.noAuth is true (CLI --no-auth override).
*/
export function isDaemonAuthActive(options?: { daemon?: { token: string }; noAuth?: boolean }): boolean {
if (options?.noAuth) {
return false;
}
if (options?.daemon?.token) {
return true;
}
if (process.env.FUSION_DAEMON_TOKEN) {
return true;
}
return false;
}
/**
* Get the daemon token from options or environment.
* Returns undefined when options.noAuth is true, regardless of env.
*/
export function getDaemonToken(options?: { daemon?: { token: string }; noAuth?: boolean }): string | undefined {
if (options?.noAuth) {
return undefined;
}
if (options?.daemon?.token) {
return options.daemon.token;
}
return process.env.FUSION_DAEMON_TOKEN;
}
/**
* Check if a request path is exempt from authentication.
*/
function isExemptPath(path: string): boolean {
return EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(exempt + "/"));
}
/**
* Constant-time string compare. Returns true only if both strings are the
* same length and byte-for-byte equal.
*/
function constantTimeEqual(provided: string, expected: Buffer): boolean {
if (provided.length !== expected.length) {
return false;
}
try {
const providedBuffer = Buffer.from(provided, "utf8");
if (providedBuffer.length !== expected.length) {
return false;
}
return timingSafeEqual(providedBuffer, expected);
} catch {
return false;
}
}
/**
* Extract a bearer token from either the `Authorization: Bearer <token>`
* header or the `fn_token=<token>` query-string fallback. The query-string
* path is only needed for transports that can't set headers (EventSource,
* WebSocket handshake).
*/
function extractTokenFromRequest(req: { headers: { authorization?: string }; url?: string }): string | undefined {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
return authHeader.slice(7);
}
if (req.url) {
try {
const parsed = new URL(req.url, "http://_placeholder_");
const fromQuery = parsed.searchParams.get(TOKEN_QUERY_PARAM);
if (fromQuery) return fromQuery;
} catch {
// Fall through — malformed URL, treat as no token.
}
}
return undefined;
}
/**
* Validate a raw HTTP upgrade request (WebSocket handshake) against the
* configured daemon token. Returns true when the request carries a valid
* bearer token, false otherwise. Accepts the token either via the
* `Authorization` header or the `fn_token` query string — browsers cannot
* set custom headers on a WebSocket constructor, so the query-string
* fallback is required for same-origin browser clients.
*
* Uses constant-time comparison to resist timing attacks.
*/
export function authenticateUpgradeRequest(token: string, req: IncomingMessage): boolean {
const expectedBuffer = Buffer.from(token, "utf8");
const provided = extractTokenFromRequest(req as { headers: { authorization?: string }; url?: string });
if (!provided) return false;
return constantTimeEqual(provided, expectedBuffer);
}
/**
* Create Express middleware that enforces bearer token authentication.
*
* Uses constant-time comparison to prevent timing attacks.
* Exempts /api/health and paths starting with /api/health/ from auth.
* Accepts the token either in the `Authorization: Bearer <token>` header
* (preferred) or as a `fn_token=<token>` query parameter — the latter is
* needed by EventSource and WebSocket clients which can't send headers.
*
* @param token - The valid bearer token
* @returns Express middleware function
*/
export function createAuthMiddleware(token: string) {
const expectedBuffer = Buffer.from(token, "utf8");
const unauthorized = (res: Response): void => {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
};
return function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// The SPA shell and static assets are public — only /api/* is gated.
if (!isApiPath(req.path)) {
next();
return;
}
// Always allow exempt paths (liveness probes)
if (isExemptPath(req.path)) {
next();
return;
}
const providedToken = extractTokenFromRequest(req);
if (!providedToken) {
unauthorized(res);
return;
}
if (!constantTimeEqual(providedToken, expectedBuffer)) {
unauthorized(res);
return;
}
next();
};
}