@startsimpli/auth 0.4.8 → 0.4.11

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.
@@ -113,36 +113,40 @@ const REMEMBER_ME_KEY = 'auth_remember_me';
113
113
 
114
114
  let _memToken: string | null = null;
115
115
 
116
- 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 {
117
120
  try {
118
- 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;
119
123
  } catch {
120
- return false;
124
+ return null;
121
125
  }
122
126
  }
123
127
 
124
128
  function _isRememberMe(): boolean {
125
- if (_storageAvailable('localStorage')) {
126
- return localStorage.getItem(REMEMBER_ME_KEY) === '1';
127
- }
128
- return false;
129
+ const ls = _resolveStorage('localStorage');
130
+ return ls ? ls.getItem(REMEMBER_ME_KEY) === '1' : false;
129
131
  }
130
132
 
131
133
  /** Enable/disable persistent token storage across browser sessions. */
132
134
  export function setRememberMe(enabled: boolean): void {
133
- if (_storageAvailable('localStorage')) {
134
- if (enabled) {
135
- localStorage.setItem(REMEMBER_ME_KEY, '1');
136
- } else {
137
- localStorage.removeItem(REMEMBER_ME_KEY);
138
- }
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);
139
141
  }
140
142
  }
141
143
 
142
144
  function _getStorage(): Storage | null {
143
- if (_isRememberMe() && _storageAvailable('localStorage')) return localStorage;
144
- if (_storageAvailable('sessionStorage')) return sessionStorage;
145
- return null;
145
+ if (_isRememberMe()) {
146
+ const ls = _resolveStorage('localStorage');
147
+ if (ls) return ls;
148
+ }
149
+ return _resolveStorage('sessionStorage');
146
150
  }
147
151
 
148
152
  export function getAccessToken(): string | null {
@@ -157,8 +161,8 @@ export function setAccessToken(token: string | null): void {
157
161
  if (token === null) {
158
162
  storage.removeItem(TOKEN_STORAGE_KEY);
159
163
  // Also clear from the other storage in case rememberMe was toggled
160
- if (_storageAvailable('sessionStorage')) sessionStorage.removeItem(TOKEN_STORAGE_KEY);
161
- if (_storageAvailable('localStorage')) localStorage.removeItem(TOKEN_STORAGE_KEY);
164
+ _resolveStorage('sessionStorage')?.removeItem(TOKEN_STORAGE_KEY);
165
+ _resolveStorage('localStorage')?.removeItem(TOKEN_STORAGE_KEY);
162
166
  // Clear ALL auth cookies so middleware doesn't redirect back
163
167
  // (prevents infinite loop when auth state is corrupted)
164
168
  _clearAllAuthCookies();
@@ -206,14 +210,55 @@ function _syncAuthCookie(token: string | null): void {
206
210
 
207
211
  const AUTH_TIMEOUT_MS = 15_000;
208
212
 
209
- /** Extract a human-readable message from a Django REST Framework error response body. */
210
- 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
+
211
245
  // Standard DRF: { detail: "..." }
212
- 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
213
254
  // Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
214
- for (const val of Object.values(d)) {
215
- if (typeof val === 'string') return val
216
- 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
217
262
  }
218
263
  return fallback
219
264
  }
@@ -345,8 +390,7 @@ export async function requestPasswordReset(email: string): Promise<void> {
345
390
 
346
391
  if (!response.ok) {
347
392
  const data = await response.json().catch(() => ({}));
348
- const d = data as Record<string, unknown>;
349
- 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');
350
394
  throw new Error(message);
351
395
  }
352
396
  }
@@ -370,8 +414,7 @@ export async function resetPassword(payload: {
370
414
 
371
415
  if (!response.ok) {
372
416
  const data = await response.json().catch(() => ({}));
373
- const d = data as Record<string, unknown>;
374
- 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');
375
418
  throw new Error(message);
376
419
  }
377
420
  }
@@ -385,8 +428,7 @@ export async function verifyEmail(token: string): Promise<void> {
385
428
 
386
429
  if (!response.ok) {
387
430
  const data = await response.json().catch(() => ({}));
388
- const d = data as Record<string, unknown>;
389
- 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');
390
432
  throw new Error(message);
391
433
  }
392
434
  }
@@ -454,18 +496,59 @@ export async function completeGoogleOAuth(code: string, state: string) {
454
496
  }
455
497
 
456
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;
511
+ }
512
+ }
513
+
457
514
  export async function refreshAccessToken(): Promise<string | null> {
458
- const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
459
- method: 'POST',
460
- headers: {
461
- 'Content-Type': 'application/json',
462
- },
463
- credentials: 'include',
464
- });
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) {
536
+ setAccessToken(null);
537
+ return null;
538
+ }
539
+
540
+ if (response.status >= 500) {
541
+ throw new TransientRefreshError(
542
+ response.status,
543
+ `refresh backend error: ${response.status} ${response.statusText}`
544
+ );
545
+ }
465
546
 
466
547
  const data = await response.json().catch(() => ({}));
467
548
 
468
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).
469
552
  setAccessToken(null);
470
553
  return null;
471
554
  }
@@ -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;
@@ -4,6 +4,18 @@
4
4
 
5
5
  import type { TokenPayload, DecodedToken } from '../types';
6
6
 
7
+ // JWT payloads are base64url-encoded. Browser atob() only accepts standard
8
+ // base64, so we have to translate `-`/`_` → `+`/`/` and restore padding before
9
+ // decoding. Without this, any token whose payload contains a url-safe char
10
+ // (common once claims grow beyond a few short ASCII fields) throws in atob
11
+ // and decodeToken silently returns null — which causes login to reject a
12
+ // valid server-issued token with "Invalid token received" and never stores it.
13
+ function _base64UrlDecode(input: string): string {
14
+ const base64 = input.replace(/-/g, '+').replace(/_/g, '/');
15
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
16
+ return atob(padded);
17
+ }
18
+
7
19
  /**
8
20
  * Decode JWT token payload (does NOT verify signature)
9
21
  */
@@ -15,7 +27,7 @@ export function decodeToken(token: string): TokenPayload | null {
15
27
  }
16
28
 
17
29
  const payload = parts[1];
18
- const decoded = JSON.parse(atob(payload));
30
+ const decoded = JSON.parse(_base64UrlDecode(payload));
19
31
  return decoded as TokenPayload;
20
32
  } catch (error) {
21
33
  console.error('Failed to decode token:', error);
@@ -60,7 +72,7 @@ export function getTokenPayload(token: string): DecodedToken | null {
60
72
  }
61
73
 
62
74
  const payload = parts[1];
63
- const decoded = JSON.parse(atob(payload));
75
+ const decoded = JSON.parse(_base64UrlDecode(payload));
64
76
 
65
77
  if (typeof decoded !== 'object' || decoded === null) {
66
78
  return null;