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.
This commit is contained in:
2026-06-14 13:39:03 +04:00
parent b507ffe7e3
commit 5acdde3e27
2 changed files with 30 additions and 1 deletions
+17
View File
@@ -106,3 +106,20 @@ describe("api client", () => {
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login 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;
});
});
+13 -1
View File
@@ -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<T>( async function authedRequest<T>(
cfg: ApiConfig, cfg: ApiConfig,
method: string, method: string,
@@ -146,7 +154,10 @@ async function authedRequest<T>(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<T> { ): Promise<T> {
let token = sessionStorage.getItem(TOKEN_KEY); 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 = { const init: RequestInit = {
method, method,
headers: { headers: {
@@ -160,6 +171,7 @@ async function authedRequest<T>(
let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
if (r.status === 401) { if (r.status === 401) {
sessionStorage.removeItem(TOKEN_KEY); sessionStorage.removeItem(TOKEN_KEY);
if (!devLoginAllowed()) throw new AuthRequiredError();
token = await login(cfg, signal); token = await login(cfg, signal);
init.headers = { ...init.headers, Authorization: `Bearer ${token}` }; init.headers = { ...init.headers, Authorization: `Bearer ${token}` };
r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body); r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);