Files
fusion/packages/dashboard/src/auth-middleware.ts
gsxdsm 22d31c4cac fix: --no-auth override, workflow revision in-place fix, state-driven heartbeats
Three orthogonal fixes bundled together so they re-land as a unit after
earlier worktree-based reverts kept wiping them individually.

1. `--no-auth` flag now actually disables auth. Previously a stale
   FUSION_DAEMON_TOKEN in .env silently re-armed bearer-token auth despite
   the CLI flag. Added a `noAuth` option to ServerOptions; auth-middleware's
   isDaemonAuthActive/getDaemonToken short-circuit to false/undefined when
   set; CLI plumbs opts.noAuth through both createServer call sites.

2. Workflow review failures no longer reset every completed step. Previously
   a single CSS nit from a workflow reviewer could drag 5+ already-approved
   steps back through plan review, code review, and re-execution because
   determineRevisionResetStart fuzzy-matched feedback tokens against step
   names. handleWorkflowRevisionRequest, handleWorkflowStepFailure, and
   sendTaskBackForFix now call a new reopenLastStepForRevision helper that
   flips only the last non-pending step back to pending (with currentStep
   rewind via a newly-accepted updateTask field) — all earlier done steps
   stay done, and the agent applies the feedback as an in-place patch per
   the updated PROMPT.md instructions. determineRevisionResetStart stays
   exported as @deprecated so existing unit tests still link.

3. Heartbeat scheduling is now state-driven. Previously a non-ephemeral
   agent with a stale runtimeConfig.enabled=false on disk would never tick
   and the Pause/Resume button couldn't arm the timer without also flipping
   that hidden flag. HeartbeatTriggerScheduler's watchAgentLifecycle now
   registers on transitions into active/running and clears on transitions
   out; the tick and assignment-trigger guards key off state + ephemeral
   classification. InProcessRuntime's created/updated listeners and startup
   scan mirror the same semantics. runtimeConfig.enabled is only retained
   for ephemeral (task-worker) opt-out.

Tests updated: agent-heartbeat.test.ts — one test renamed from "skips
registration when enabled is false" (obsolete behavior) to
"registers regardless of the legacy enabled flag"; 4 assignment-watching
tests now pass a realistic `state: "active"` on mock agents. 207 heartbeat
tests + 330 executor tests pass.

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

179 lines
5.7 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 that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
/**
* 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();
};
}