From 5acdde3e271138bb439303ba0534277ed49acae2 Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 13:39:03 +0400 Subject: [PATCH] fix(api): close second silent dev-login path in authedRequest Oracle round-4 finding: authedRequest() called the private login() helper whenever no bearer token existed AND on 401 retry. That helper posts to /api/v1/auth/dev-login, so any authenticated API call could silently issue dev-login regardless of UI gating. - authedRequest checks devLoginAllowed() (VITE_ENABLE_DEV_LOGIN !== 'false') before calling login(). When dev-login is disabled it throws AuthRequiredError instead. - 401 retry path gated the same way. - New AuthRequiredError exported so callers (store, scenes) can route unauthenticated users to the login page instead of swallowing. - src/lib/api.test.ts: regression test 'throws AuthRequiredError instead of silently calling /dev-login when no token'. With VITE_ENABLE_DEV_LOGIN=false api.me() rejects with /Sign in required/ and no POST to /dev-login is observed. 30/30 unit tests green. --- src/lib/api.test.ts | 17 +++++++++++++++++ src/lib/api.ts | 14 +++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index a4f09ac..01dda7f 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -106,3 +106,20 @@ describe("api client", () => { expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login }); }); + +describe("authedRequest fail-closed when dev-login disabled", () => { + beforeEach(() => { + import.meta.env.VITE_ENABLE_DEV_LOGIN = "false"; + }); + it("throws AuthRequiredError instead of silently calling /dev-login when no token", async () => { + const calls = mockFetch([ + (url) => + url.endsWith("/api/v1/auth/me") + ? new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 }) + : undefined, + ]); + await expect(api.me()).rejects.toThrow(/Sign in required/); + expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(0); + delete (import.meta.env as any).VITE_ENABLE_DEV_LOGIN; + }); +}); diff --git a/src/lib/api.ts b/src/lib/api.ts index 601c79f..21e243d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -138,6 +138,14 @@ async function instrumentedFetch( } } +export class AuthRequiredError extends Error { + constructor(msg = "Sign in required") { super(msg); this.name = "AuthRequiredError"; } +} + +function devLoginAllowed(): boolean { + return (import.meta.env?.VITE_ENABLE_DEV_LOGIN ?? "true") !== "false"; +} + async function authedRequest( cfg: ApiConfig, method: string, @@ -146,7 +154,10 @@ async function authedRequest( signal?: AbortSignal, ): Promise { let token = sessionStorage.getItem(TOKEN_KEY); - if (!token) token = await login(cfg, signal); + if (!token) { + if (!devLoginAllowed()) throw new AuthRequiredError(); + token = await login(cfg, signal); + } const init: RequestInit = { method, headers: { @@ -160,6 +171,7 @@ async function authedRequest( let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); if (r.status === 401) { sessionStorage.removeItem(TOKEN_KEY); + if (!devLoginAllowed()) throw new AuthRequiredError(); token = await login(cfg, signal); init.headers = { ...init.headers, Authorization: `Bearer ${token}` }; r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);