diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index 6cf208ae4..1bcf05392 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -7,6 +7,7 @@ import { hashDaemonPassword, isBearerTokenValidAsync, isBearerTokenValid, + shouldBypassBearerAuth, } from "./auth.js"; const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; @@ -52,4 +53,16 @@ describe("daemon bearer validator", () => { expect(extractWsBearerToken(protocol)).toBe("secret.with.dots"); expect(extractWsBearerToken("paseo.other.secret")).toBeNull(); }); + + test("bypasses bearer auth for preflight, liveness, and capability-token routes", () => { + // Preflight is always bypassed regardless of path. + expect(shouldBypassBearerAuth("OPTIONS", "/api/status")).toBe(true); + // Unauthenticated liveness probe. + expect(shouldBypassBearerAuth("GET", "/api/health")).toBe(true); + // Guarded by its own single-use download token, not the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/files/download")).toBe(true); + // Everything else stays behind the daemon password. + expect(shouldBypassBearerAuth("GET", "/api/status")).toBe(false); + expect(shouldBypassBearerAuth("POST", "/api/files/upload")).toBe(false); + }); }); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 2247597f9..4e2e7ee7f 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -118,9 +118,25 @@ export function createRequireBearerMiddleware( }; } +// Routes that authenticate via their own capability and therefore must not be +// gated a second time behind the daemon password. +const BEARER_AUTH_BYPASS_PATHS = new Set([ + // Unauthenticated liveness probe. + "/api/health", + // Guarded by a single-use download token (crypto-random UUID, 60s TTL, + // consumed on first use) that is only ever issued over the + // already-authenticated WebSocket. The token IS the capability for this + // route. Requiring the daemon password on top of it breaks browser and + // Electron downloads: those trigger the download via an anchor navigation, + // which cannot attach an `Authorization` header. The download endpoint still + // rejects requests without a valid token (400/403), so dropping the bearer + // here does not make the route unauthenticated. + "/api/files/download", +]); + export function shouldBypassBearerAuth(method: string, path: string): boolean { if (method === "OPTIONS") { return true; } - return path === "/api/health"; + return BEARER_AUTH_BYPASS_PATHS.has(path); } diff --git a/packages/server/src/server/bootstrap-auth.test.ts b/packages/server/src/server/bootstrap-auth.test.ts index a8d98ef4a..2fa7c860a 100644 --- a/packages/server/src/server/bootstrap-auth.test.ts +++ b/packages/server/src/server/bootstrap-auth.test.ts @@ -65,18 +65,39 @@ describe("daemon bearer auth", () => { auth: { password: CORRECT_PASSWORD_HASH }, }); try { - const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + const missing = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); expect(missing.status).toBe(401); - const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const wrong = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer wrong-password" }, }); expect(wrong.status).toBe(401); - const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`, { + const correct = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { headers: { Authorization: "Bearer correct-password" }, }); - expect(correct.status).toBe(400); + expect(correct.status).toBe(200); + } finally { + await daemonHandle.close(); + } + }); + + test("allows file downloads with only a capability token when password is configured", async () => { + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: CORRECT_PASSWORD_HASH }, + }); + try { + // No bearer at all: the route is reachable, but the download token store + // rejects the request because no token was supplied (400, not 401). + const missingToken = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/files/download`); + expect(missingToken.status).toBe(400); + + // An invalid token is rejected by the token store (403, not 401) — proving + // the token, not the daemon password, is what guards this route. + const invalidToken = await fetch( + `http://127.0.0.1:${daemonHandle.port}/api/files/download?token=invalid-token`, + ); + expect(invalidToken.status).toBe(403); } finally { await daemonHandle.close(); }