feat(auth): fetch wrapper with 401 redirect + authApi

This commit is contained in:
Nora
2026-06-22 15:09:49 -06:00
parent 5cdfc05b41
commit f1f2eee69e
2 changed files with 214 additions and 0 deletions
+83
View File
@@ -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" });
});
});
+131
View File
@@ -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=<current>` 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<T = unknown>(
path: string,
init?: RequestInit
): Promise<T> {
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);
},
};