feat(FN-1831): merge fusion/fn-1831
This commit is contained in:
161
packages/dashboard/src/__tests__/auth-middleware.test.ts
Normal file
161
packages/dashboard/src/__tests__/auth-middleware.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
129
packages/dashboard/src/auth-middleware.ts
Normal file
129
packages/dashboard/src/auth-middleware.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user