feat(dashboard): bearer-token auth with browser persistence + MIT license
Pre-release polish. Two related changes bundled because they both land the project on public-release footing: Dashboard auth - fn dashboard now gates the HTTP API + terminal/badge WebSockets behind a bearer token by default. Token resolution order: --token flag, FUSION_DASHBOARD_TOKEN env, FUSION_DAEMON_TOKEN env (back-compat), or an auto-generated fn_<32 hex>. --no-auth disables. The startup banner prints a click-to-open URL with ?token=<token> embedded. - Auth middleware now also accepts fn_token=<token> as a query-string fallback so EventSource and WebSocket clients (which can't set custom headers) still authenticate. - setupTerminalWebSocket / setupBadgeWebSocket now refuse unauthenticated upgrades with a proper 401 + socket close. - Frontend: new auth.ts module captures ?token= off the URL into localStorage (key fn.authToken), strips it from the visible URL via replaceState, and installs a window.fetch wrapper that injects Authorization: Bearer <token> on every same-origin /api/* request. EventSource/WebSocket URL builders (api.ts, sse-bus.ts, useTerminal, useBadgeWebSocket) route through appendTokenQuery(). MIT license - LICENSE file at repo root. - license: "MIT" on root package.json and every packages/*/package.json, plus description/bugs metadata on the CLI package. Docs - docs/cli-reference.md documents --token / --no-auth / FUSION_DASHBOARD_TOKEN and the click-to-open auth flow. - docs/getting-started.md, docs/docker.md, README.md point at the new flow and the CLI reference section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"name": "@gsxdsm/fusion",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion CLI: HTTP API server, daemon, dashboard launcher, and task tooling for the Fusion AI coding agent.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Runfusion/Fusion/issues"
|
||||
},
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"pi-package"
|
||||
|
||||
@@ -269,6 +269,8 @@ Options:
|
||||
--project, -P <name> Target a specific project (bypasses CWD detection)
|
||||
--port, -p <port> Dashboard/serve port (default: 4040)
|
||||
--host <host> Serve host (default: 127.0.0.1 — localhost only; pass 0.0.0.0 to expose)
|
||||
--token <token> Dashboard/daemon bearer token. Default: $FUSION_DASHBOARD_TOKEN, $FUSION_DAEMON_TOKEN, or auto-generated.
|
||||
--no-auth Disable dashboard bearer-token auth (local-only; not recommended on 0.0.0.0)
|
||||
--interactive Interactive mode (port selection for dashboard, issue selection for import)
|
||||
--paused Start with engine paused (automation disabled)
|
||||
--dev Start dashboard only (no AI engine)
|
||||
@@ -506,7 +508,10 @@ async function main() {
|
||||
const interactive = args.includes("--interactive");
|
||||
const dashHostIdx = args.indexOf("--host");
|
||||
const host = dashHostIdx !== -1 && dashHostIdx + 1 < args.length ? args[dashHostIdx + 1] : undefined;
|
||||
await runDashboard(port, { paused, dev, interactive, host });
|
||||
const noAuth = args.includes("--no-auth");
|
||||
const dashTokenIdx = args.indexOf("--token");
|
||||
const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined;
|
||||
await runDashboard(port, { paused, dev, interactive, host, noAuth, token });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker, getEnabledPiExtensionPaths } from "@fusion/core";
|
||||
import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv } from "@fusion/dashboard";
|
||||
@@ -193,10 +194,30 @@ async function resolveRuntimeProjectPath(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string } = {}) {
|
||||
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean; host?: string; noAuth?: boolean; token?: string } = {}) {
|
||||
// Default to localhost so the dashboard (and its shell-capable terminal API)
|
||||
// is not exposed on the LAN. Pass --host 0.0.0.0 explicitly to opt-in.
|
||||
const selectedHost = opts.host ?? "127.0.0.1";
|
||||
|
||||
// ── Bearer-token auth ────────────────────────────────────────────────
|
||||
//
|
||||
// By default the dashboard API is gated by a bearer token so that when the
|
||||
// server is bound to a non-localhost interface (e.g. `pnpm dev dashboard`
|
||||
// which injects --host 0.0.0.0 for LAN testing) nearby users can't hit the
|
||||
// terminal or exec endpoints uninvited. Precedence:
|
||||
// 1. `opts.token` — explicit override (mostly for tests)
|
||||
// 2. `FUSION_DASHBOARD_TOKEN` — user-provided env
|
||||
// 3. `FUSION_DAEMON_TOKEN` — back-compat with daemon mode
|
||||
// 4. auto-generated random token (printed at startup so the user can auth)
|
||||
// `--no-auth` skips the middleware entirely. The token is embedded in the
|
||||
// launch URL (as `?token=...`) so the user can click once and the browser
|
||||
// stores it to localStorage for subsequent loads.
|
||||
const dashboardAuthToken: string | undefined = opts.noAuth
|
||||
? undefined
|
||||
: opts.token
|
||||
?? process.env.FUSION_DASHBOARD_TOKEN
|
||||
?? process.env.FUSION_DAEMON_TOKEN
|
||||
?? `fn_${randomBytes(16).toString("hex")}`;
|
||||
ensureProcessDiagnostics();
|
||||
|
||||
// Handle interactive port selection
|
||||
@@ -606,6 +627,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
skillsAdapter,
|
||||
https: loadTlsCredentialsFromEnv(),
|
||||
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals) => {
|
||||
@@ -787,6 +809,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
pluginRunner: pluginLoader,
|
||||
skillsAdapter,
|
||||
https: loadTlsCredentialsFromEnv(),
|
||||
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -918,10 +941,29 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
}
|
||||
}
|
||||
|
||||
// Compose the user-visible URL. When we're bound to a non-localhost
|
||||
// interface (LAN testing), surface the actual host so the URL is
|
||||
// usable from another device. Otherwise keep it as `localhost` for
|
||||
// the nicer click-to-open experience.
|
||||
const displayHost =
|
||||
selectedHost === "0.0.0.0" || selectedHost === "::" ? selectedHost : "localhost";
|
||||
const baseUrl = `http://${displayHost}:${actualPort}`;
|
||||
const tokenizedUrl = dashboardAuthToken
|
||||
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
|
||||
: baseUrl;
|
||||
|
||||
console.log();
|
||||
console.log(` fn board`);
|
||||
console.log(` ────────────────────────`);
|
||||
console.log(` → http://localhost:${actualPort}`);
|
||||
console.log(` → ${baseUrl}`);
|
||||
if (dashboardAuthToken) {
|
||||
console.log(` Auth: bearer token required`);
|
||||
console.log(` Token: ${dashboardAuthToken}`);
|
||||
console.log(` Open: ${tokenizedUrl}`);
|
||||
console.log(` (the browser stores the token so you only need to click once)`);
|
||||
} else {
|
||||
console.log(` Auth: disabled (--no-auth)`);
|
||||
}
|
||||
console.log();
|
||||
console.log(` Tasks stored in .fusion/tasks/`);
|
||||
console.log(` Merge: AI-assisted (conflict resolution + commit messages)`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/core",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -65,6 +65,7 @@ import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
|
||||
import type { MilestoneValidationTelemetry } from "./components/mission-types";
|
||||
import { appendTokenQuery } from "./auth";
|
||||
|
||||
// Re-export skills types for use by hooks and components
|
||||
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry };
|
||||
@@ -1889,7 +1890,8 @@ function createResilientEventSource(
|
||||
if (closedByUser) return;
|
||||
|
||||
const nextUrl = appendLastEventId(url, lastSeenEventId);
|
||||
const source = new EventSource(nextUrl);
|
||||
// EventSource can't set headers — carry the bearer token via `fn_token=`.
|
||||
const source = new EventSource(appendTokenQuery(nextUrl));
|
||||
eventSource = source;
|
||||
|
||||
source.onopen = () => {
|
||||
|
||||
188
packages/dashboard/app/auth.ts
Normal file
188
packages/dashboard/app/auth.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Dashboard authentication: token capture, storage, and injection.
|
||||
*
|
||||
* Flow:
|
||||
* 1. On first load, if `?token=<value>` is present in the URL, capture it,
|
||||
* store it in localStorage, and strip it from the visible URL so the
|
||||
* secret doesn't end up in browser history or shared screenshots.
|
||||
* 2. `getAuthToken()` returns the stored token (or undefined if none).
|
||||
* 3. `installAuthFetch()` wraps `window.fetch` to inject
|
||||
* `Authorization: Bearer <token>` on every same-origin `/api/*` call,
|
||||
* and rewrites EventSource-style URLs by appending `fn_token=<token>`
|
||||
* (EventSource can't set headers).
|
||||
* 4. `appendTokenQuery()` and `withTokenHeader()` are helpers for places
|
||||
* that construct URLs directly (WebSocket upgrades, EventSource).
|
||||
*
|
||||
* If no token is configured (dashboard started with `--no-auth`), all of the
|
||||
* above no-ops — the fetch wrapper adds nothing and `appendTokenQuery` is
|
||||
* identity.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "fn.authToken";
|
||||
const URL_PARAM = "token";
|
||||
/** Query param name used when we can't set an Authorization header (EventSource, WebSocket). */
|
||||
export const QUERY_TOKEN_PARAM = "fn_token";
|
||||
|
||||
let cachedToken: string | undefined;
|
||||
let captureAttempted = false;
|
||||
|
||||
function readStoredToken(): string | undefined {
|
||||
try {
|
||||
const value = window.localStorage.getItem(STORAGE_KEY);
|
||||
return value && value.length > 0 ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredToken(token: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, token);
|
||||
} catch {
|
||||
// Private mode / storage disabled — fall through; token stays in memory.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `?token=...` param off the current URL (if present) and stash it
|
||||
* into localStorage, then remove it from the visible URL so the secret is not
|
||||
* retained in browser history. Returns the token if one was captured.
|
||||
*
|
||||
* Safe to call multiple times — only the first call does work.
|
||||
*/
|
||||
function captureTokenFromUrl(): string | undefined {
|
||||
if (captureAttempted || typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
captureAttempted = true;
|
||||
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const token = url.searchParams.get(URL_PARAM);
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
writeStoredToken(token);
|
||||
url.searchParams.delete(URL_PARAM);
|
||||
const cleaned = url.pathname + (url.search ? url.search : "") + url.hash;
|
||||
window.history.replaceState(window.history.state, "", cleaned);
|
||||
return token;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the bearer token in effect for this session, if any. */
|
||||
export function getAuthToken(): string | undefined {
|
||||
if (cachedToken !== undefined) {
|
||||
return cachedToken;
|
||||
}
|
||||
const captured = captureTokenFromUrl();
|
||||
if (captured) {
|
||||
cachedToken = captured;
|
||||
return captured;
|
||||
}
|
||||
const stored = readStoredToken();
|
||||
if (stored) {
|
||||
cachedToken = stored;
|
||||
return stored;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Clear the stored token (e.g., on a 401 response). */
|
||||
export function clearAuthToken(): void {
|
||||
cachedToken = undefined;
|
||||
try {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Ignore — worst case, a stale token sits in memory until reload.
|
||||
}
|
||||
}
|
||||
|
||||
/** Append `fn_token=<token>` to a URL so EventSource / WebSocket can auth. */
|
||||
export function appendTokenQuery(url: string): string {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return url;
|
||||
}
|
||||
try {
|
||||
// Support both absolute and relative URLs by using a dummy base.
|
||||
const base = url.startsWith("/") || !/^[a-z]+:\/\//i.test(url)
|
||||
? new URL(url, window.location.origin)
|
||||
: new URL(url);
|
||||
base.searchParams.set(QUERY_TOKEN_PARAM, token);
|
||||
// Preserve the original form (relative vs absolute).
|
||||
return url.startsWith("/")
|
||||
? base.pathname + base.search + base.hash
|
||||
: base.toString();
|
||||
} catch {
|
||||
// URL too malformed to parse — fall back to naive concatenation.
|
||||
const sep = url.includes("?") ? "&" : "?";
|
||||
return `${url}${sep}${QUERY_TOKEN_PARAM}=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge an Authorization header onto an existing HeadersInit, if we have a token. */
|
||||
export function withTokenHeader(init?: HeadersInit): HeadersInit | undefined {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return init;
|
||||
}
|
||||
const headers = new Headers(init ?? {});
|
||||
if (!headers.has("Authorization")) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monkey-patch `window.fetch` once so every same-origin `/api/*` request gets
|
||||
* a bearer token. This covers direct `fetch()` callers that don't route
|
||||
* through the `api()` helper without requiring us to touch each one.
|
||||
*/
|
||||
export function installAuthFetch(): void {
|
||||
if (typeof window === "undefined" || (window as any).__fnAuthFetchInstalled) {
|
||||
return;
|
||||
}
|
||||
(window as any).__fnAuthFetchInstalled = true;
|
||||
|
||||
// Ensure token is captured-from-URL before the first fetch fires.
|
||||
getAuthToken();
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function patchedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const urlString = typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input.url;
|
||||
|
||||
// Only attach the token for same-origin /api/* requests.
|
||||
const isApiCall = (() => {
|
||||
try {
|
||||
const resolved = new URL(urlString, window.location.origin);
|
||||
if (resolved.origin !== window.location.origin) return false;
|
||||
return resolved.pathname.startsWith("/api/") || resolved.pathname === "/api";
|
||||
} catch {
|
||||
return urlString.startsWith("/api/") || urlString === "/api";
|
||||
}
|
||||
})();
|
||||
|
||||
if (!isApiCall) {
|
||||
return originalFetch(input, init);
|
||||
}
|
||||
|
||||
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
||||
if (!headers.has("Authorization")) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
return originalFetch(input, { ...init, headers });
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import type { IssueInfo, PrInfo } from "@fusion/core";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
|
||||
interface BadgeUpdatedMessage {
|
||||
type: "badge:updated";
|
||||
@@ -194,7 +195,9 @@ class BadgeWebSocketStore {
|
||||
if (this.projectId) {
|
||||
url += `?projectId=${encodeURIComponent(this.projectId)}`;
|
||||
}
|
||||
const ws = new WebSocket(url);
|
||||
// Bearer token must be on the URL — WebSocket construction doesn't
|
||||
// support custom headers. No-op when auth is disabled.
|
||||
const ws = new WebSocket(appendTokenQuery(url));
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
|
||||
export type ConnectionStatus = "connecting" | "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
@@ -278,7 +279,10 @@ export function useTerminal(sessionId: string | null, projectId?: string): UseTe
|
||||
wsUrl += `&projectId=${encodeURIComponent(projectId)}`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
// Carry the bearer token on the URL — WebSocket `new WebSocket` can't set
|
||||
// an Authorization header. `appendTokenQuery` adds `fn_token=<token>`
|
||||
// when auth is active and returns the URL unchanged otherwise.
|
||||
const ws = new WebSocket(appendTokenQuery(wsUrl));
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
|
||||
@@ -2,8 +2,15 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RootErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { App } from "./App";
|
||||
import { installAuthFetch } from "./auth";
|
||||
import "./styles.css";
|
||||
|
||||
// Install the bearer-token fetch wrapper before React mounts so every API
|
||||
// call (including ones fired synchronously during the first render) picks up
|
||||
// the token that was either captured from `?token=` in the launch URL or
|
||||
// stored from a previous session.
|
||||
installAuthFetch();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RootErrorBoundary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { appendTokenQuery } from "./auth";
|
||||
|
||||
// Shared EventSource multiplexer.
|
||||
//
|
||||
// Browsers cap HTTP/1.1 connections to a single origin at ~6. Each native
|
||||
@@ -86,7 +88,10 @@ function openChannel(channel: Channel): void {
|
||||
channel.reconnectTimer = null;
|
||||
}
|
||||
|
||||
const es = new EventSource(channel.url);
|
||||
// EventSource can't set custom headers, so the bearer token must ride on
|
||||
// the URL as `fn_token=<token>`. `appendTokenQuery` is a no-op when no
|
||||
// token is configured.
|
||||
const es = new EventSource(appendTokenQuery(channel.url));
|
||||
channel.es = es;
|
||||
|
||||
es.addEventListener("open", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/dashboard",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion dashboard: React UI and HTTP API server for monitoring and controlling the Fusion AI coding agent.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
|
||||
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"];
|
||||
@@ -30,7 +38,7 @@ export function isDaemonAuthActive(options?: { daemon?: { token: string } }): bo
|
||||
/**
|
||||
* Get the daemon token from options or environment.
|
||||
*/
|
||||
function getDaemonToken(options?: { daemon?: { token: string } }): string | undefined {
|
||||
export function getDaemonToken(options?: { daemon?: { token: string } }): string | undefined {
|
||||
if (options?.daemon?.token) {
|
||||
return options.daemon.token;
|
||||
}
|
||||
@@ -44,17 +52,85 @@ 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 {
|
||||
// Always allow exempt paths
|
||||
@@ -63,67 +139,17 @@ export function createAuthMiddleware(token: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Valid bearer token required",
|
||||
});
|
||||
const providedToken = extractTokenFromRequest(req);
|
||||
if (!providedToken) {
|
||||
unauthorized(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse Bearer scheme
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
res.status(401).json({
|
||||
error: "Unauthorized",
|
||||
message: "Valid bearer token required",
|
||||
});
|
||||
if (!constantTimeEqual(providedToken, expectedBuffer)) {
|
||||
unauthorized(res);
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
import { ChatManager } from "./chat.js";
|
||||
import { stopAllDevServers } from "./dev-server-routes.js";
|
||||
import type { SkillsAdapter } from "./skills-adapter.js";
|
||||
import { createAuthMiddleware } from "./auth-middleware.js";
|
||||
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -866,12 +866,25 @@ export function setupTerminalWebSocket(
|
||||
// Default terminal service for stale eviction (uses default store's root dir)
|
||||
const defaultTerminalService = getTerminalService(store.getRootDir());
|
||||
|
||||
// Resolve the daemon token once so every upgrade picks up the same value.
|
||||
const wsDaemonToken = getDaemonToken(options);
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
|
||||
if (pathname !== "/api/terminal/ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
// When daemon auth is active, refuse WebSocket upgrades that don't
|
||||
// carry a valid bearer token. The token can come from the Authorization
|
||||
// header (rare for browser WebSocket clients) or the `fn_token` query
|
||||
// param (what our own client uses).
|
||||
if (wsDaemonToken && !authenticateUpgradeRequest(wsDaemonToken, req)) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (upgraded) => {
|
||||
wss.emit("connection", upgraded, req);
|
||||
});
|
||||
@@ -1130,12 +1143,22 @@ export function setupBadgeWebSocket(
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
// Resolve the daemon token once per server so every upgrade picks up the
|
||||
// same value. See the equivalent block in setupTerminalWebSocket above.
|
||||
const badgeWsDaemonToken = getDaemonToken(options);
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
|
||||
if (pathname !== "/api/ws") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (badgeWsDaemonToken && !authenticateUpgradeRequest(badgeWsDaemonToken, req)) {
|
||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (upgraded) => {
|
||||
wss.emit("connection", upgraded, req);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/desktop",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion desktop: Electron wrapper around the Fusion dashboard for macOS, Windows, and Linux.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/engine",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion engine: executor, merger, scheduler, and automation runtime for the Fusion AI coding agent.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/mobile",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion mobile: Capacitor wrapper around the Fusion dashboard for iOS and Android.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion plugin SDK: types and helpers for authoring third-party plugins that extend the Fusion dashboard and engine.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@fusion/tui",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Fusion TUI: terminal UI for interacting with the Fusion task store and AI coding agent.",
|
||||
"homepage": "https://github.com/Runfusion/Fusion#readme",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user