feat(FN-1831): merge fusion/fn-1831

This commit is contained in:
gsxdsm
2026-04-15 04:01:02 -07:00
parent cd5d4551b6
commit bae5d1d812
9 changed files with 1713 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Request, Response, NextFunction } from "express";
import { createAuthMiddleware, isDaemonAuthActive } from "../auth-middleware.js";
describe("createAuthMiddleware", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let nextFn: NextFunction;
beforeEach(() => {
mockReq = {
path: "/api/tasks",
headers: {},
};
mockRes = {
status: vi.fn().mockReturnThis() as unknown as Response["status"],
json: vi.fn().mockReturnThis() as unknown as Response["json"],
};
nextFn = vi.fn();
});
it("returns 401 when Authorization header is missing", () => {
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: "Unauthorized",
message: "Valid bearer token required",
});
expect(nextFn).not.toHaveBeenCalled();
});
it("returns 401 when Authorization header uses wrong scheme", () => {
mockReq.headers = { authorization: "Basic dXNlcjpwYXNz" };
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: "Unauthorized",
message: "Valid bearer token required",
});
expect(nextFn).not.toHaveBeenCalled();
});
it("returns 401 when token is wrong", () => {
mockReq.headers = { authorization: "Bearer wrong_token" };
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({
error: "Unauthorized",
message: "Valid bearer token required",
});
expect(nextFn).not.toHaveBeenCalled();
});
it("calls next() when token matches", () => {
const token = "fn_abc123def456789";
mockReq.headers = { authorization: `Bearer ${token}` };
const middleware = createAuthMiddleware(token);
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(nextFn).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it("exempts /api/health path without token", () => {
mockReq.path = "/api/health";
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(nextFn).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it("exempts paths starting with /api/health/", () => {
mockReq.path = "/api/health/check";
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(nextFn).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it("handles tokens of different lengths without crashing", () => {
// Test with shorter token
mockReq.headers = { authorization: "Bearer short" };
const shortToken = "fn_verylongtoken1234567890123456789012345678901234567890";
const middleware = createAuthMiddleware(shortToken);
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
it("handles malformed bearer header (missing space)", () => {
mockReq.headers = { authorization: "Bearertoken" };
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
it("handles empty bearer token", () => {
mockReq.headers = { authorization: "Bearer " };
const middleware = createAuthMiddleware("fn_abc123def456789");
middleware(mockReq as Request, mockRes as Response, nextFn);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(nextFn).not.toHaveBeenCalled();
});
});
describe("isDaemonAuthActive", () => {
const originalEnv = process.env.FUSION_DAEMON_TOKEN;
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.FUSION_DAEMON_TOKEN;
} else {
process.env.FUSION_DAEMON_TOKEN = originalEnv;
}
});
it("returns true when daemon option with token is provided", () => {
const result = isDaemonAuthActive({ daemon: { token: "fn_abc123" } });
expect(result).toBe(true);
});
it("returns true when FUSION_DAEMON_TOKEN env var is set", () => {
process.env.FUSION_DAEMON_TOKEN = "fn_xyz789";
const result = isDaemonAuthActive();
expect(result).toBe(true);
});
it("returns false when no daemon option and env var not set", () => {
delete process.env.FUSION_DAEMON_TOKEN;
const result = isDaemonAuthActive();
expect(result).toBe(false);
});
it("prefers daemon option over env var", () => {
process.env.FUSION_DAEMON_TOKEN = "fn_env_token";
const result = isDaemonAuthActive({ daemon: { token: "fn_option_token" } });
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,129 @@
/**
* 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";
/** Paths that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
/**
* Check if daemon auth should be active.
* Auth is enabled when FUSION_DAEMON_TOKEN env var is set OR daemon options are provided.
*/
export function isDaemonAuthActive(options?: { daemon?: { token: string } }): boolean {
// Check explicit daemon option
if (options?.daemon?.token) {
return true;
}
// Check environment variable
if (process.env.FUSION_DAEMON_TOKEN) {
return true;
}
return false;
}
/**
* Get the daemon token from options or environment.
*/
function getDaemonToken(options?: { daemon?: { token: string } }): string | 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 + "/"));
}
/**
* 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.
*
* @param token - The valid bearer token
* @returns Express middleware function
*/
export function createAuthMiddleware(token: string) {
const expectedBuffer = Buffer.from(token, "utf8");
return function authMiddleware(req: Request, res: Response, next: NextFunction): void {
// Always allow exempt paths
if (isExemptPath(req.path)) {
next();
return;
}
// Extract Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
// Parse Bearer scheme
if (!authHeader.startsWith("Bearer ")) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
const providedToken = authHeader.slice(7); // Remove "Bearer " prefix
// Fast path: check length first to avoid unnecessary crypto calls
if (providedToken.length !== expectedBuffer.length) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
// Constant-time comparison to prevent timing attacks
try {
const providedBuffer = Buffer.from(providedToken, "utf8");
// Ensure buffers are the same length (they should be due to length check above)
if (providedBuffer.length !== expectedBuffer.length) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
if (!timingSafeEqual(providedBuffer, expectedBuffer)) {
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
} catch {
// Buffer encoding issues or other crypto errors
res.status(401).json({
error: "Unauthorized",
message: "Valid bearer token required",
});
return;
}
// Token is valid
next();
};
}

View File

@@ -41,6 +41,7 @@ import {
} from "./milestone-slice-interview.js";
import { ChatManager } from "./chat.js";
import type { SkillsAdapter } from "./skills-adapter.js";
import { createAuthMiddleware } from "./auth-middleware.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -192,6 +193,9 @@ export interface ServerOptions {
onProjectFirstAccessed?: (projectId: string) => void;
/** Optional SkillsAdapter for skills discovery, execution toggling, and catalog fetching */
skillsAdapter?: SkillsAdapter;
/** Daemon mode configuration with bearer token authentication.
* When provided, all API requests (except /api/health) require valid bearer token. */
daemon?: { token: string };
}
type DashboardExpressApp = ReturnType<typeof express> & {
@@ -301,6 +305,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Standard JSON parsing for all other routes
app.use(express.json());
// Daemon mode: bearer token authentication middleware
// Auth is enabled when daemon option is provided OR FUSION_DAEMON_TOKEN env var is set
// The middleware itself exempts /api/health for liveness probes
const daemonToken = options?.daemon?.token ?? process.env.FUSION_DAEMON_TOKEN;
if (daemonToken) {
app.use(createAuthMiddleware(daemonToken));
}
// Initialize terminal service with project root
getTerminalService(store.getRootDir());