api.ts 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * Authenticated fetch with refresh-on-401.
  3. *
  4. * Flow:
  5. * 1. Try the request with the current access token.
  6. * 2. On 401, attempt ONE refresh (POST /v1/auth/refresh with the
  7. * refresh cookie — httpOnly + Secure + SameSite=Lax). If that
  8. * succeeds, retry the original request once.
  9. * 3. On second 401 (refresh failed or refresh returned 401), clear
  10. * local state and force a navigation to /login.
  11. *
  12. * Threading: all callers await fetchWithAuth; refresh is guarded by
  13. * an in-flight Promise so concurrent 401s coalesce into one refresh.
  14. */
  15. import { getAccessToken, clearTokens, setTokens, getRefreshFailureUrl } from './auth-state';
  16. let inflightRefresh: Promise<boolean> | null = null;
  17. async function tryRefresh(): Promise<boolean> {
  18. if (inflightRefresh) return inflightRefresh;
  19. inflightRefresh = (async () => {
  20. try {
  21. const r = await fetch('/v1/auth/refresh', {
  22. method: 'POST',
  23. credentials: 'include',
  24. });
  25. if (!r.ok) return false;
  26. const data = (await r.json()) as { access_token?: string; refresh_token?: string };
  27. if (!data.access_token || !data.refresh_token) return false;
  28. setTokens({ access: data.access_token, refresh: data.refresh_token });
  29. return true;
  30. } catch {
  31. return false;
  32. } finally {
  33. inflightRefresh = null;
  34. }
  35. })();
  36. return inflightRefresh;
  37. }
  38. export interface FetchOptions extends RequestInit {
  39. /** Skip the auth refresh-on-401 retry once. Used for the login
  40. * endpoint itself, which is public. */
  41. skipRefresh?: boolean;
  42. }
  43. export class ApiError extends Error {
  44. status: number;
  45. body: unknown;
  46. constructor(status: number, body: unknown, message: string) {
  47. super(message);
  48. this.status = status;
  49. this.body = body;
  50. }
  51. }
  52. export async function fetchWithAuth(
  53. url: string,
  54. opts: FetchOptions = {},
  55. ): Promise<Response> {
  56. const headers = new Headers(opts.headers);
  57. const token = getAccessToken();
  58. if (token && !headers.has('Authorization')) {
  59. headers.set('Authorization', `Bearer ${token}`);
  60. }
  61. const res = await fetch(url, { ...opts, headers, credentials: 'include' });
  62. if (res.status !== 401 || opts.skipRefresh) {
  63. return res;
  64. }
  65. const ok = await tryRefresh();
  66. if (!ok) {
  67. clearTokens();
  68. const target = getRefreshFailureUrl();
  69. if (target && typeof window !== 'undefined') {
  70. window.location.href = target;
  71. }
  72. return res;
  73. }
  74. const retryHeaders = new Headers(opts.headers);
  75. const newToken = getAccessToken();
  76. if (newToken && !retryHeaders.has('Authorization')) {
  77. retryHeaders.set('Authorization', `Bearer ${newToken}`);
  78. }
  79. return fetch(url, { ...opts, headers: retryHeaders, credentials: 'include' });
  80. }
  81. export async function apiGet<T>(url: string): Promise<T> {
  82. const res = await fetchWithAuth(url);
  83. if (!res.ok) {
  84. const body = await safeJson(res);
  85. throw new ApiError(res.status, body, `${res.status} ${res.statusText}`);
  86. }
  87. return res.json() as Promise<T>;
  88. }
  89. export async function apiSend<T>(
  90. method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
  91. url: string,
  92. body?: unknown,
  93. ): Promise<T> {
  94. const res = await fetchWithAuth(url, {
  95. method,
  96. headers: body ? { 'Content-Type': 'application/json' } : undefined,
  97. body: body ? JSON.stringify(body) : undefined,
  98. });
  99. if (!res.ok) {
  100. const errBody = await safeJson(res);
  101. throw new ApiError(res.status, errBody, `${method} ${url} → ${res.status}`);
  102. }
  103. if (res.status === 204) return undefined as T;
  104. return res.json() as Promise<T>;
  105. }
  106. async function safeJson(res: Response): Promise<unknown> {
  107. try {
  108. return await res.json();
  109. } catch {
  110. return null;
  111. }
  112. }