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>
179 lines
5.7 KiB
TypeScript
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();
|
|
};
|
|
}
|