From 55a298f05fb7ef531ff863afd7badb30d185b749 Mon Sep 17 00:00:00 2001 From: Nora Date: Mon, 22 Jun 2026 15:09:49 -0600 Subject: [PATCH] feat(auth): fetch wrapper with 401 redirect + authApi --- src/auth/api.test.ts | 83 +++++++++++++++++++++++++++ src/auth/api.ts | 131 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 src/auth/api.test.ts create mode 100644 src/auth/api.ts diff --git a/src/auth/api.test.ts b/src/auth/api.test.ts new file mode 100644 index 0000000..710439b --- /dev/null +++ b/src/auth/api.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("auth/api fetch wrapper", () => { + let originalFetch: typeof globalThis.fetch; + let originalLocation: Location; + + beforeEach(() => { + originalFetch = globalThis.fetch; + originalLocation = window.location; + delete (window as any).__navCalls; + (window as any).__navCalls = []; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + window.location = originalLocation as any; + }); + + it("redirects to /login on 401 response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + headers: new Headers(), + json: async () => ({ error: "session_expired" }), + } as Response); + + // Stub window.location.href setter to capture navigation without + // actually navigating (jsdom does not implement location.href assignment). + let href = originalLocation.href; + Object.defineProperty(window, "location", { + configurable: true, + get: () => ({ + ...originalLocation, + get href() { + return href; + }, + set href(v: string) { + (window as any).__navCalls.push(v); + href = v; + }, + }), + }); + + const { authedFetch } = await import("./api"); + await expect(authedFetch("/api/anything")).rejects.toThrow(); + expect((window as any).__navCalls).toContainEqual(expect.stringContaining("/login")); + }); + + it("does NOT redirect on 403", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + statusText: "Forbidden", + headers: new Headers(), + json: async () => ({ error: "forbidden" }), + } as Response); + Object.defineProperty(window, "location", { + configurable: true, + get: () => ({ + ...originalLocation, + set href(_: string) { + throw new Error("should not nav"); + }, + }), + }); + + const { authedFetch } = await import("./api"); + await expect(authedFetch("/api/anything")).rejects.toThrow(); + }); + + it("returns parsed JSON on 200", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: async () => ({ hello: "world" }), + } as Response); + const { authedFetch } = await import("./api"); + const data = await authedFetch("/api/anything"); + expect(data).toEqual({ hello: "world" }); + }); +}); diff --git a/src/auth/api.ts b/src/auth/api.ts new file mode 100644 index 0000000..54dd7a9 --- /dev/null +++ b/src/auth/api.ts @@ -0,0 +1,131 @@ +/** + * Auth-aware fetch wrapper. + * + * Every authenticated backend call in the SPA goes through `authedFetch`. + * Responsibilities: + * + * 1. Attach `credentials: "include"` so the `cyclone_session` HttpOnly + * cookie rides along on cross-origin XHR calls. + * 2. Tag every request with `Accept: application/json` by default so + * the FastAPI backend knows to return its structured error shape + * (`{"error": "...", "detail": "..."}`) on failures. + * 3. On a 401 from anything OTHER than the auth endpoints themselves, + * redirect to `/login?next=` so the operator sees a real + * sign-in screen instead of an infinite stream of failing queries. + * 401 from `/api/auth/*` is the normal "bad password" path — let + * the caller handle it. + * 4. On any other non-2xx, parse the JSON body and throw an `ApiError` + * carrying `status`, the backend's `error` code, and the `detail` + * string. The Login page and hooks both branch on `.code`. + * 5. On 204, return `undefined` (so callers can `await` without + * blowing up on `res.json()` of an empty body). + * 6. Otherwise return the parsed JSON. + * + * `authApi` is the typed wrapper around the three auth endpoints + * (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`). + */ + +const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? ""; + +function joinUrl(path: string): string { + if (BASE_URL) return `${BASE_URL.replace(/\/$/, "")}${path}`; + return path; +} + +/** + * Error thrown for any non-2xx `authedFetch` response. Carries the HTTP + * `status`, the backend's `error` code (so callers can branch on + * `err.code === "invalid_credentials"`, etc.), and the optional `detail` + * string for surfacing in toasts. + * + * Distinct from the `ApiError` in `src/lib/api.ts` — that one only + * carries `status` and is used by the existing pages; this one is the + * richer auth-aware variant that the Login page + hooks depend on. + */ +export class ApiError extends Error { + status: number; + code: string; + detail?: string; + constructor(status: number, code: string, detail?: string) { + super(detail ?? code); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.detail = detail; + } +} + +function redirectToLogin() { + const next = encodeURIComponent(window.location.pathname + window.location.search); + window.location.href = `/login?next=${next}`; +} + +export async function authedFetch( + path: string, + init?: RequestInit +): Promise { + const res = await fetch(joinUrl(path), { + credentials: "include", + headers: { Accept: "application/json", ...(init?.headers ?? {}) }, + ...init, + }); + if (res.status === 401 && !path.startsWith("/api/auth/")) { + redirectToLogin(); + throw new ApiError(401, "session_expired"); + } + if (!res.ok) { + let body: any = null; + try { + body = await res.json(); + } catch { + /* no body */ + } + const code = body?.error ?? "error"; + const detail = body?.detail ?? res.statusText; + throw new ApiError(res.status, code, detail); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +// --------------------------------------------------------------------------- +// Auth-specific endpoints. `login` and `logout` use raw `fetch` because +// they bypass the 401-redirect behavior on purpose — a 401 from +// /api/auth/login is "wrong password", not "session expired". +// --------------------------------------------------------------------------- + +export const authApi = { + async login(username: string, password: string) { + const res = await fetch(joinUrl("/api/auth/login"), { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new ApiError( + res.status, + body.error ?? "error", + body.detail ?? res.statusText + ); + } + return res.json(); + }, + async me() { + return authedFetch("/api/auth/me"); + }, + async logout() { + // Fire-and-forget. A network error here is fine — the server has + // already cleared the cookie or the operator is signing out because + // they're about to be disconnected. Either way, the client should + // drop the user into the unauthenticated state. + await fetch(joinUrl("/api/auth/logout"), { + method: "POST", + credentials: "include", + }).catch(() => undefined); + }, +};