| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- /**
- * Authenticated fetch with refresh-on-401.
- *
- * Flow:
- * 1. Try the request with the current access token.
- * 2. On 401, attempt ONE refresh (POST /v1/auth/refresh with the
- * refresh cookie — httpOnly + Secure + SameSite=Lax). If that
- * succeeds, retry the original request once.
- * 3. On second 401 (refresh failed or refresh returned 401), clear
- * local state and force a navigation to /login.
- *
- * Threading: all callers await fetchWithAuth; refresh is guarded by
- * an in-flight Promise so concurrent 401s coalesce into one refresh.
- */
- import { getAccessToken, clearTokens, setTokens, getRefreshFailureUrl } from './auth-state';
- let inflightRefresh: Promise<boolean> | null = null;
- async function tryRefresh(): Promise<boolean> {
- if (inflightRefresh) return inflightRefresh;
- inflightRefresh = (async () => {
- try {
- const r = await fetch('/v1/auth/refresh', {
- method: 'POST',
- credentials: 'include',
- });
- if (!r.ok) return false;
- const data = (await r.json()) as { access_token?: string; refresh_token?: string };
- if (!data.access_token || !data.refresh_token) return false;
- setTokens({ access: data.access_token, refresh: data.refresh_token });
- return true;
- } catch {
- return false;
- } finally {
- inflightRefresh = null;
- }
- })();
- return inflightRefresh;
- }
- export interface FetchOptions extends RequestInit {
- /** Skip the auth refresh-on-401 retry once. Used for the login
- * endpoint itself, which is public. */
- skipRefresh?: boolean;
- }
- export class ApiError extends Error {
- status: number;
- body: unknown;
- constructor(status: number, body: unknown, message: string) {
- super(message);
- this.status = status;
- this.body = body;
- }
- }
- export async function fetchWithAuth(
- url: string,
- opts: FetchOptions = {},
- ): Promise<Response> {
- const headers = new Headers(opts.headers);
- const token = getAccessToken();
- if (token && !headers.has('Authorization')) {
- headers.set('Authorization', `Bearer ${token}`);
- }
- const res = await fetch(url, { ...opts, headers, credentials: 'include' });
- if (res.status !== 401 || opts.skipRefresh) {
- return res;
- }
- const ok = await tryRefresh();
- if (!ok) {
- clearTokens();
- const target = getRefreshFailureUrl();
- if (target && typeof window !== 'undefined') {
- window.location.href = target;
- }
- return res;
- }
- const retryHeaders = new Headers(opts.headers);
- const newToken = getAccessToken();
- if (newToken && !retryHeaders.has('Authorization')) {
- retryHeaders.set('Authorization', `Bearer ${newToken}`);
- }
- return fetch(url, { ...opts, headers: retryHeaders, credentials: 'include' });
- }
- export async function apiGet<T>(url: string): Promise<T> {
- const res = await fetchWithAuth(url);
- if (!res.ok) {
- const body = await safeJson(res);
- throw new ApiError(res.status, body, `${res.status} ${res.statusText}`);
- }
- return res.json() as Promise<T>;
- }
- export async function apiSend<T>(
- method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
- url: string,
- body?: unknown,
- ): Promise<T> {
- const res = await fetchWithAuth(url, {
- method,
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
- body: body ? JSON.stringify(body) : undefined,
- });
- if (!res.ok) {
- const errBody = await safeJson(res);
- throw new ApiError(res.status, errBody, `${method} ${url} → ${res.status}`);
- }
- if (res.status === 204) return undefined as T;
- return res.json() as Promise<T>;
- }
- async function safeJson(res: Response): Promise<unknown> {
- try {
- return await res.json();
- } catch {
- return null;
- }
- }
|