Safe-by-default dashboard exposure: localhost-only unless you explicitly opt in
to a public bind with a token.
- src/cli.ts: host default 0.0.0.0 → 127.0.0.1 (localhost-only; --host 0.0.0.0 to
expose, which then requires a token when the auth flag is on).
- src/server.ts startServer guards (before listen): SELF-LOCKOUT (flag on + no
token → throw — you'd otherwise brick the dashboard with no recovery short of
editing .env) + PUBLIC-EXPOSURE gate (flag on + 0.0.0.0 + no token → throw).
Both use __authState (mutable seam) so they're testable. __authState.bound set
from cfg.host. When the flag is off, the guards are skipped (back-compat
escape hatch — the bind still defaults to 127.0.0.1).
- src/server.ts authenticate: E7.2 bind-gating — enforce only on a PUBLIC bind
(0.0.0.0); 127.0.0.1 is localhost-trusted (requireAuth routes stay open even
with the flag on). The refuse-to-start guard ensures a 0.0.0.0 bind has a token.
- src/server.ts routes: GET /api/auth/status (open; {authRequired, bound,
relayConfigured, foundryConfigured} — booleans only, no secret values),
POST /api/auth/login (open; validates token constant-time, sets an HttpOnly
SameSite=Strict cookie; 401 invalid credentials on mismatch, no leak; empty
token = unset; no token configured → 401), POST /api/auth/logout (clears
cookie, Max-Age=0).
- src/dashboard.html: first-run login card (token input, shown when authRequired
&& no stored token), a shared apiFetch wrapper (attaches the stored token as a
Bearer header, on 401 → show login), checkAuth gating init; bare fetch('/api/')
calls migrated to apiFetch (auth endpoints stay plain fetch).
- .env.example: documents DASHBOARD_AUTH_TOKEN + ENABLE_AUTH_MIDDLEWARE.
- tests: e7-1-auth/dispatch updated for bind-gating (enforcement tests set
bound=0.0.0.0; + a 127.0.0.1 no-enforcement test). e7-2-auth.test.ts (13
tests): auth-status (off/localhost/public/no-secret-leak), login
(valid-cookie/invalid-401/empty/unset), logout (clears), refuse-to-start
(self-lockout / 0.0.0.0-no-token / token-set-passes / flag-off-no-guard).
tsc clean; 194 passing project-wide (18 pre-existing fixture-missing unchanged).
Co-Authored-By: Claude <noreply@anthropic.com>
152 lines
7.6 KiB
TypeScript
152 lines
7.6 KiB
TypeScript
// E7.2 — localhost-default bind + refuse-to-start + first-run auth prompt.
|
|
//
|
|
// Covers: /api/auth/status (authRequired flag), /api/auth/login (valid/invalid →
|
|
// cookie / 401), /api/auth/logout (clears cookie), and the startServer refuse-to-
|
|
// start guards (self-lockout + 0.0.0.0-without-token).
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
|
import { ClassicLevel } from "classic-level";
|
|
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import type { Server } from "node:http";
|
|
|
|
import { startServer, __authState } from "../src/server.js";
|
|
|
|
let dir: string;
|
|
let server: Server;
|
|
let baseURL: string;
|
|
const savedEnabled = __authState.enabled;
|
|
const savedToken = __authState.token;
|
|
const savedBound = __authState.bound;
|
|
|
|
beforeAll(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "e7-2-"));
|
|
await mkdir(join(dir, "refined"), { recursive: true });
|
|
await mkdir(join(dir, "cc"), { recursive: true });
|
|
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
|
|
await jdb.open(); await jdb.close();
|
|
const { server: srv } = await startServer({
|
|
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
|
|
outDir: join(dir, "out"), mode: "dev", port: 0, host: "127.0.0.1",
|
|
});
|
|
server = srv;
|
|
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
__authState.enabled = savedEnabled; __authState.token = savedToken; __authState.bound = savedBound;
|
|
await new Promise<void>((r) => server.close(() => r()));
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
beforeEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
|
|
afterEach(() => { __authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1"; });
|
|
|
|
async function req(path: string, init: RequestInit = {}): Promise<{ code: number; body: unknown; headers: Headers }> {
|
|
const r = await fetch(`${baseURL}${path}`, init);
|
|
return { code: r.status, body: r.headers.get("content-type")?.includes("json") ? await r.json() : await r.text(), headers: r.headers };
|
|
}
|
|
|
|
describe("E7.2 /api/auth/status", () => {
|
|
it("flag off → authRequired:false, bound:127.0.0.1", async () => {
|
|
const r = await req("/api/auth/status");
|
|
expect(r.code).toBe(200);
|
|
expect((r.body as { authRequired: boolean }).authRequired).toBe(false);
|
|
expect((r.body as { bound: string }).bound).toBe("127.0.0.1");
|
|
});
|
|
it("flag on + 127.0.0.1 (localhost trusted) → authRequired:false", async () => {
|
|
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "127.0.0.1";
|
|
const r = await req("/api/auth/status");
|
|
expect((r.body as { authRequired: boolean }).authRequired).toBe(false);
|
|
});
|
|
it("flag on + 0.0.0.0 + token → authRequired:true", async () => {
|
|
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0";
|
|
const r = await req("/api/auth/status");
|
|
expect((r.body as { authRequired: boolean }).authRequired).toBe(true);
|
|
});
|
|
it("never echoes the token or secret config (booleans only)", async () => {
|
|
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0";
|
|
const r = await req("/api/auth/status");
|
|
const body = JSON.stringify(r.body);
|
|
expect(body).not.toContain("s3cret");
|
|
});
|
|
});
|
|
|
|
describe("E7.2 /api/auth/login + logout", () => {
|
|
beforeEach(() => { __authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "0.0.0.0"; });
|
|
|
|
it("valid token → 200 + sets an HttpOnly SameSite=Strict auth_token cookie", async () => {
|
|
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "s3cret" }) });
|
|
expect(r.code).toBe(200);
|
|
const cookie = r.headers.get("set-cookie") ?? "";
|
|
expect(cookie).toContain("auth_token=s3cret");
|
|
expect(cookie).toContain("HttpOnly");
|
|
expect(cookie).toContain("SameSite=Strict");
|
|
});
|
|
it("wrong token → 401 invalid credentials (no leak)", async () => {
|
|
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "wrong" }) });
|
|
expect(r.code).toBe(401);
|
|
expect((r.body as { error: string }).error).toBe("invalid credentials");
|
|
expect(r.headers.get("set-cookie")).toBe(null); // no cookie set on failure
|
|
});
|
|
it("empty/whitespace token → 401 (treated as unset)", async () => {
|
|
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: " " }) });
|
|
expect(r.code).toBe(401);
|
|
});
|
|
it("no token configured (DASHBOARD_AUTH_TOKEN unset) → 401 (can't log in)", async () => {
|
|
__authState.token = "";
|
|
const r = await req("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: "anything" }) });
|
|
expect(r.code).toBe(401);
|
|
});
|
|
it("logout → 200 + clears the cookie (Max-Age=0)", async () => {
|
|
const r = await req("/api/auth/logout", { method: "POST" });
|
|
expect(r.code).toBe(200);
|
|
const cookie = r.headers.get("set-cookie") ?? "";
|
|
expect(cookie).toContain("auth_token=");
|
|
expect(cookie).toContain("Max-Age=0");
|
|
});
|
|
});
|
|
|
|
describe("E7.2 startServer refuse-to-start guards", () => {
|
|
// These throw BEFORE opening the journal, so no LevelDB is needed.
|
|
const dummyCfg = (host: string) => ({
|
|
journal: join(dir, "no-such-journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
|
|
outDir: join(dir, "out"), mode: "dev" as const, port: 0, host,
|
|
});
|
|
|
|
it("flag on + no token → refuses to start (self-lockout guard)", async () => {
|
|
__authState.enabled = true; __authState.token = ""; __authState.bound = "127.0.0.1";
|
|
await expect(startServer(dummyCfg("127.0.0.1"))).rejects.toThrow(/ENABLE_AUTH_MIDDLEWARE=on requires DASHBOARD_AUTH_TOKEN/);
|
|
});
|
|
it("flag on + 0.0.0.0 + no token → refuses to start (public-exposure gate)", async () => {
|
|
__authState.enabled = true; __authState.token = ""; __authState.bound = "127.0.0.1";
|
|
// The self-lockout guard fires first (no token at all), so this throws the
|
|
// self-lockout message — both guards refuse; assert it refuses (either message).
|
|
await expect(startServer(dummyCfg("0.0.0.0"))).rejects.toThrow();
|
|
});
|
|
it("flag on + token set → does NOT throw on the guards (would proceed to open the journal)", async () => {
|
|
__authState.enabled = true; __authState.token = "s3cret"; __authState.bound = "127.0.0.1";
|
|
// With a token, the guards pass; startServer then tries to open the journal
|
|
// (dummyCfg points at a non-existent path) → throws a DIFFERENT error (LevelDB
|
|
// open), NOT the guard message. Assert it does NOT throw the guard message.
|
|
try {
|
|
await startServer(dummyCfg("0.0.0.0"));
|
|
expect.unreachable("should have thrown on the missing journal");
|
|
} catch (e) {
|
|
expect((e as Error).message).not.toMatch(/DASHBOARD_AUTH_TOKEN/);
|
|
expect((e as Error).message).not.toMatch(/refusing to bind/);
|
|
}
|
|
});
|
|
it("flag off → no guard (back-compat: 0.0.0.0 + no token would proceed)", async () => {
|
|
__authState.enabled = false; __authState.token = ""; __authState.bound = "127.0.0.1";
|
|
try {
|
|
await startServer(dummyCfg("0.0.0.0"));
|
|
expect.unreachable("should have thrown on the missing journal");
|
|
} catch (e) {
|
|
// No guard message — it got past the guards to the journal open.
|
|
expect((e as Error).message).not.toMatch(/DASHBOARD_AUTH_TOKEN/);
|
|
expect((e as Error).message).not.toMatch(/refusing to bind/);
|
|
}
|
|
});
|
|
}); |