@startsimpli/auth 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,16 @@
2
2
  * Functional auth API for Django backend
3
3
  *
4
4
  * Stateless functions for authentication flows: sign in, register, OAuth, token refresh, etc.
5
- * Uses fetch (browser/Node) and getCsrfToken from shared utils.
6
- * No Next.js dependency.
5
+ * Uses fetch (browser/Node). No Next.js dependency.
6
+ *
7
+ * CSRF note: the Django auth endpoints (signin, register, token/refresh, logout,
8
+ * OAuth) are all @csrf_exempt or JWT-authenticated. The frontend does not send
9
+ * X-CSRFToken headers and does not fetch /auth/csrf/ — any CSRF plumbing here
10
+ * would be dead weight (and previously caused refresh to fail whenever the CSRF
11
+ * endpoint was flaky).
7
12
  */
8
13
 
9
- import { getCsrfToken, deleteCookie } from '../utils/cookies';
14
+ import { deleteCookie } from '../utils/cookies';
10
15
  import { decodeToken } from '../utils/token';
11
16
 
12
17
  // --- Types ---
@@ -28,10 +33,27 @@ export interface AuthUser {
28
33
  export type OnSessionExpiredCallback = () => void;
29
34
 
30
35
  let _onSessionExpired: OnSessionExpiredCallback | null = null;
36
+ let _sessionExpiredFiredAt = 0;
37
+ const SESSION_EXPIRED_COOLDOWN_MS = 5000;
31
38
 
32
39
  /** Register a callback for unrecoverable 401s (typically set by AuthProvider). */
33
40
  export function setOnSessionExpired(cb: OnSessionExpiredCallback | null): void {
34
41
  _onSessionExpired = cb;
42
+ if (cb !== null) _sessionExpiredFiredAt = 0;
43
+ }
44
+
45
+ /**
46
+ * Fire the registered session-expired callback at most once per cooldown window.
47
+ * Exposed so non-authFetch callers (e.g. the @startsimpli/api FetchWrapper) can
48
+ * route 401-after-refresh through the same sink that authFetch uses, instead of
49
+ * each caller wiring up its own redirect.
50
+ */
51
+ export function notifySessionExpired(): void {
52
+ if (!_onSessionExpired) return;
53
+ const now = Date.now();
54
+ if (now - _sessionExpiredFiredAt < SESSION_EXPIRED_COOLDOWN_MS) return;
55
+ _sessionExpiredFiredAt = now;
56
+ _onSessionExpired();
35
57
  }
36
58
 
37
59
  // --- Endpoint paths (Django backend defaults) ---
@@ -91,36 +113,40 @@ const REMEMBER_ME_KEY = 'auth_remember_me';
91
113
 
92
114
  let _memToken: string | null = null;
93
115
 
94
- function _storageAvailable(type: 'sessionStorage' | 'localStorage'): boolean {
116
+ // Resolve storage via globalThis so tests that stub the storage globals
117
+ // (vi.stubGlobal('localStorage', ...)) see the stub, and non-browser envs
118
+ // safely return null.
119
+ function _resolveStorage(type: 'sessionStorage' | 'localStorage'): Storage | null {
95
120
  try {
96
- return typeof window !== 'undefined' && !!window[type];
121
+ const s = (globalThis as unknown as Record<string, Storage | undefined>)[type];
122
+ return s && typeof s.getItem === 'function' ? s : null;
97
123
  } catch {
98
- return false;
124
+ return null;
99
125
  }
100
126
  }
101
127
 
102
128
  function _isRememberMe(): boolean {
103
- if (_storageAvailable('localStorage')) {
104
- return localStorage.getItem(REMEMBER_ME_KEY) === '1';
105
- }
106
- return false;
129
+ const ls = _resolveStorage('localStorage');
130
+ return ls ? ls.getItem(REMEMBER_ME_KEY) === '1' : false;
107
131
  }
108
132
 
109
133
  /** Enable/disable persistent token storage across browser sessions. */
110
134
  export function setRememberMe(enabled: boolean): void {
111
- if (_storageAvailable('localStorage')) {
112
- if (enabled) {
113
- localStorage.setItem(REMEMBER_ME_KEY, '1');
114
- } else {
115
- localStorage.removeItem(REMEMBER_ME_KEY);
116
- }
135
+ const ls = _resolveStorage('localStorage');
136
+ if (!ls) return;
137
+ if (enabled) {
138
+ ls.setItem(REMEMBER_ME_KEY, '1');
139
+ } else {
140
+ ls.removeItem(REMEMBER_ME_KEY);
117
141
  }
118
142
  }
119
143
 
120
144
  function _getStorage(): Storage | null {
121
- if (_isRememberMe() && _storageAvailable('localStorage')) return localStorage;
122
- if (_storageAvailable('sessionStorage')) return sessionStorage;
123
- return null;
145
+ if (_isRememberMe()) {
146
+ const ls = _resolveStorage('localStorage');
147
+ if (ls) return ls;
148
+ }
149
+ return _resolveStorage('sessionStorage');
124
150
  }
125
151
 
126
152
  export function getAccessToken(): string | null {
@@ -135,8 +161,8 @@ export function setAccessToken(token: string | null): void {
135
161
  if (token === null) {
136
162
  storage.removeItem(TOKEN_STORAGE_KEY);
137
163
  // Also clear from the other storage in case rememberMe was toggled
138
- if (_storageAvailable('sessionStorage')) sessionStorage.removeItem(TOKEN_STORAGE_KEY);
139
- if (_storageAvailable('localStorage')) localStorage.removeItem(TOKEN_STORAGE_KEY);
164
+ _resolveStorage('sessionStorage')?.removeItem(TOKEN_STORAGE_KEY);
165
+ _resolveStorage('localStorage')?.removeItem(TOKEN_STORAGE_KEY);
140
166
  // Clear ALL auth cookies so middleware doesn't redirect back
141
167
  // (prevents infinite loop when auth state is corrupted)
142
168
  _clearAllAuthCookies();
@@ -184,14 +210,55 @@ function _syncAuthCookie(token: string | null): void {
184
210
 
185
211
  const AUTH_TIMEOUT_MS = 15_000;
186
212
 
187
- /** Extract a human-readable message from a Django REST Framework error response body. */
188
- function extractApiError(d: Record<string, unknown>, fallback: string): string {
213
+ /**
214
+ * Extract a human-readable message from a Django REST Framework error response body.
215
+ *
216
+ * Handles the shapes we've seen in practice:
217
+ * { detail: "..." } → the string
218
+ * { detail: ["...", "..."] } → first string
219
+ * { detail: { token: ["Invalid..."] } } → first nested string
220
+ * { email: ["already exists"] } → first field-level string
221
+ * { non_field_errors: ["..."] } → first field-level string
222
+ * { error: "CODE", detail: { field: ["..."] } } → first nested string
223
+ *
224
+ * @internal Shared with AuthClient; still considered implementation detail
225
+ * of the auth package. Do not rely on from outside `@startsimpli/auth`.
226
+ */
227
+ export function extractApiError(d: Record<string, unknown>, fallback: string): string {
228
+ const pluck = (val: unknown): string | null => {
229
+ if (typeof val === 'string') return val
230
+ if (Array.isArray(val) && val.length > 0) {
231
+ for (const item of val) {
232
+ const s = pluck(item)
233
+ if (s) return s
234
+ }
235
+ }
236
+ if (val && typeof val === 'object') {
237
+ for (const v of Object.values(val as Record<string, unknown>)) {
238
+ const s = pluck(v)
239
+ if (s) return s
240
+ }
241
+ }
242
+ return null
243
+ }
244
+
189
245
  // Standard DRF: { detail: "..." }
190
- if (typeof d.detail === 'string') return d.detail
246
+ const fromDetail = pluck(d.detail)
247
+ if (fromDetail) return fromDetail
248
+ // Some backend shapes use `error` as the human-readable message (e.g.
249
+ // our Django auth errors: { "error": "No active account...", "code": "unauthorized" }).
250
+ // Prefer this over field-level probing so we don't accidentally return a
251
+ // code like "unauthorized" from a sibling field.
252
+ const fromError = pluck(d.error)
253
+ if (fromError) return fromError
191
254
  // Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
192
- for (const val of Object.values(d)) {
193
- if (typeof val === 'string') return val
194
- if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') return val[0]
255
+ // Skip known meta/code fields so `{ code: "unauthorized" }` doesn't leak
256
+ // an internal identifier as a user-facing message.
257
+ const META_KEYS = new Set(['detail', 'error', 'code', 'statusCode', 'status', 'timestamp'])
258
+ for (const [key, val] of Object.entries(d)) {
259
+ if (META_KEYS.has(key)) continue
260
+ const s = pluck(val)
261
+ if (s) return s
195
262
  }
196
263
  return fallback
197
264
  }
@@ -323,8 +390,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
323
390
 
324
391
  if (!response.ok) {
325
392
  const data = await response.json().catch(() => ({}));
326
- const d = data as Record<string, unknown>;
327
- const message = (d?.detail || d?.error || 'Failed to send reset link') as string;
393
+ const message = extractApiError(data as Record<string, unknown>, 'Failed to send reset link');
328
394
  throw new Error(message);
329
395
  }
330
396
  }
@@ -348,8 +414,7 @@ export async function resetPassword(payload: {
348
414
 
349
415
  if (!response.ok) {
350
416
  const data = await response.json().catch(() => ({}));
351
- const d = data as Record<string, unknown>;
352
- const message = (d?.detail || d?.error || 'Failed to reset password') as string;
417
+ const message = extractApiError(data as Record<string, unknown>, 'Failed to reset password');
353
418
  throw new Error(message);
354
419
  }
355
420
  }
@@ -363,8 +428,7 @@ export async function verifyEmail(token: string): Promise<void> {
363
428
 
364
429
  if (!response.ok) {
365
430
  const data = await response.json().catch(() => ({}));
366
- const d = data as Record<string, unknown>;
367
- const message = (d?.detail || d?.error || 'Failed to verify email') as string;
431
+ const message = extractApiError(data as Record<string, unknown>, 'Failed to verify email');
368
432
  throw new Error(message);
369
433
  }
370
434
  }
@@ -431,50 +495,60 @@ export async function completeGoogleOAuth(code: string, state: string) {
431
495
  return parsed;
432
496
  }
433
497
 
434
- async function fetchCsrfToken(): Promise<void> {
435
- if (getCsrfToken()) return;
436
- const maxAttempts = 3;
437
- let lastError: unknown;
438
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
439
- try {
440
- await fetch(resolveAuthUrl(`${API_BASE}/auth/csrf/`), {
441
- credentials: 'include',
442
- cache: 'no-store',
443
- });
444
- if (getCsrfToken()) return;
445
- } catch (err) {
446
- lastError = err;
447
- }
448
- if (attempt < maxAttempts - 1) {
449
- await new Promise(r => setTimeout(r, 500));
450
- }
498
+
499
+ /**
500
+ * Thrown by refreshAccessToken when the backend is transiently unavailable
501
+ * (5xx, network error). Distinct from "session is dead" (401/403), which
502
+ * returns null. Callers should NOT log the user out on this error — they
503
+ * should surface the original request failure and let the user retry.
504
+ */
505
+ export class TransientRefreshError extends Error {
506
+ readonly status: number | null;
507
+ constructor(status: number | null, message: string) {
508
+ super(message);
509
+ this.name = 'TransientRefreshError';
510
+ this.status = status;
451
511
  }
452
- throw new Error(
453
- `[auth] CSRF token fetch failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError ?? 'no token set')}`
454
- );
455
512
  }
456
513
 
457
514
  export async function refreshAccessToken(): Promise<string | null> {
458
- await fetchCsrfToken();
459
- const csrfToken = getCsrfToken();
460
- if (!csrfToken) {
461
- console.warn('[auth] No CSRF token available — cannot refresh. Clearing session.');
515
+ let response: Response;
516
+ try {
517
+ response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
518
+ method: 'POST',
519
+ headers: {
520
+ 'Content-Type': 'application/json',
521
+ },
522
+ credentials: 'include',
523
+ });
524
+ } catch (err) {
525
+ // Network error / timeout — backend unreachable. Do NOT clear the token;
526
+ // keep the user logged in and let them retry.
527
+ throw new TransientRefreshError(
528
+ null,
529
+ `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`
530
+ );
531
+ }
532
+
533
+ // 401/403 = session explicitly dead. 400 = malformed request (also dead).
534
+ // 5xx = backend flake; don't clear session.
535
+ if (response.status === 401 || response.status === 403 || response.status === 400) {
462
536
  setAccessToken(null);
463
537
  return null;
464
538
  }
465
539
 
466
- const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
467
- method: 'POST',
468
- headers: {
469
- 'Content-Type': 'application/json',
470
- 'X-CSRFToken': csrfToken,
471
- },
472
- credentials: 'include',
473
- });
540
+ if (response.status >= 500) {
541
+ throw new TransientRefreshError(
542
+ response.status,
543
+ `refresh backend error: ${response.status} ${response.statusText}`
544
+ );
545
+ }
474
546
 
475
547
  const data = await response.json().catch(() => ({}));
476
548
 
477
549
  if (!response.ok) {
550
+ // Unexpected non-ok that's neither auth-fail nor 5xx. Treat conservatively
551
+ // as session-dead (keeps behavior safe for middle of the road 4xxs).
478
552
  setAccessToken(null);
479
553
  return null;
480
554
  }
@@ -512,7 +586,6 @@ export async function getMe(): Promise<AuthUser | null> {
512
586
  }
513
587
 
514
588
  export async function signOut(): Promise<void> {
515
- const csrfToken = getCsrfToken();
516
589
  const token = getAccessToken();
517
590
 
518
591
  try {
@@ -520,7 +593,6 @@ export async function signOut(): Promise<void> {
520
593
  method: 'POST',
521
594
  headers: {
522
595
  'Content-Type': 'application/json',
523
- ...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
524
596
  ...(token ? { Authorization: `Bearer ${token}` } : {}),
525
597
  },
526
598
  credentials: 'include',
@@ -583,7 +655,7 @@ export async function authFetch(
583
655
  if (!refreshed || retryResponse.status === 401) {
584
656
  // Refresh failed or retried request still unauthorized — session is dead.
585
657
  setAccessToken(null);
586
- _onSessionExpired?.();
658
+ notifySessionExpired();
587
659
  }
588
660
 
589
661
  return retryResponse;
@@ -17,6 +17,17 @@ export interface UseAuthReturn {
17
17
  logout: () => Promise<void>;
18
18
  refreshUser: () => Promise<void>;
19
19
  getAccessToken: () => Promise<string | null>;
20
+ register: (payload: {
21
+ email: string;
22
+ password: string;
23
+ passwordConfirm: string;
24
+ name?: string;
25
+ firstName?: string;
26
+ lastName?: string;
27
+ }) => Promise<void>;
28
+ signInWithGoogle: (redirectTo?: string) => Promise<string>;
29
+ completeGoogleCallback: (code: string, state: string) => Promise<void>;
30
+ hydrateSession: (session: Session) => void;
20
31
  }
21
32
 
22
33
  /**
@@ -31,6 +42,10 @@ export function useAuth(): UseAuthReturn {
31
42
  logout,
32
43
  refreshUser,
33
44
  getAccessToken,
45
+ register,
46
+ signInWithGoogle,
47
+ completeGoogleCallback,
48
+ hydrateSession,
34
49
  } = useAuthContext();
35
50
 
36
51
  return {
@@ -42,6 +57,10 @@ export function useAuth(): UseAuthReturn {
42
57
  logout,
43
58
  refreshUser,
44
59
  getAccessToken,
60
+ register,
61
+ signInWithGoogle,
62
+ completeGoogleCallback,
63
+ hydrateSession,
45
64
  };
46
65
  }
47
66
 
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * Next.js middleware helpers for authentication.
3
3
  *
4
- * Checks multiple cookie sources in priority order:
5
- * 1. `auth_session` – non-HttpOnly cookie set by the client after login.
6
- * Works reliably on Vercel where rewrites don't pass through Set-Cookie.
7
- * 2. `refresh_token` – HttpOnly cookie set by Django. Works locally where
8
- * the rewrite proxy forwards Set-Cookie headers.
9
- * 3. `access_token` – legacy/alternative cookie name.
4
+ * Checks client-managed access-token cookies:
5
+ * 1. `auth_session` – non-HttpOnly cookie the client writes after login.
6
+ * 2. `access_token` – legacy/alternative cookie name for the same.
7
+ *
8
+ * We intentionally do NOT treat the HttpOnly `refresh_token` cookie as
9
+ * proof of authentication. The refresh token survives client-side logout
10
+ * (Chromium can drop server-issued delete-cookie responses when attributes
11
+ * diverge), which would let a "logged out" user bounce back into protected
12
+ * routes via the middleware. Access tokens are short-lived and kept in
13
+ * sync with the in-memory session, so they're the correct signal here.
10
14
  */
11
15
 
12
16
  import { NextRequest, NextResponse } from 'next/server';
13
17
  import { isTokenExpired } from '../utils';
14
18
 
15
19
  /** Cookie names checked for a valid JWT, in priority order. */
16
- const AUTH_COOKIE_NAMES = ['auth_session', 'refresh_token', 'access_token'] as const;
20
+ const AUTH_COOKIE_NAMES = ['auth_session', 'access_token'] as const;
17
21
 
18
22
  /** Find the first valid (non-expired) JWT from known auth cookies. */
19
23
  function findValidToken(request: NextRequest): string | null {
@@ -45,6 +45,12 @@ export interface AuthUser {
45
45
  isEmailVerified: boolean;
46
46
  createdAt: string;
47
47
  updatedAt: string;
48
+ // Optional fields populated when the backend includes them. These are
49
+ // opt-in on the app side so consumers that don't need them don't have to
50
+ // deal with undefined narrowing.
51
+ isStaff?: boolean;
52
+ isActive?: boolean;
53
+ name?: string | null;
48
54
  // Company/team context (if applicable)
49
55
  companies?: Array<{
50
56
  id: string;