@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.
- package/package.json +10 -9
- package/src/__tests__/auth-client-oauth-register.test.ts +264 -0
- package/src/__tests__/auth-fetch.test.ts +0 -15
- package/src/__tests__/auth-functions.test.ts +259 -7
- package/src/__tests__/setup.ts +38 -0
- package/src/__tests__/useauth-shape-contract.test.ts +68 -0
- package/src/client/auth-client.ts +339 -8
- package/src/client/auth-context.tsx +106 -6
- package/src/client/functions.ts +140 -68
- package/src/client/use-auth.ts +19 -0
- package/src/server/middleware.ts +11 -7
- package/src/types/index.ts +6 -0
package/src/client/functions.ts
CHANGED
|
@@ -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)
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
124
|
+
return null;
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
function _isRememberMe(): boolean {
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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()
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
/**
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
658
|
+
notifySessionExpired();
|
|
587
659
|
}
|
|
588
660
|
|
|
589
661
|
return retryResponse;
|
package/src/client/use-auth.ts
CHANGED
|
@@ -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
|
|
package/src/server/middleware.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Next.js middleware helpers for authentication.
|
|
3
3
|
*
|
|
4
|
-
* Checks
|
|
5
|
-
* 1. `auth_session` – non-HttpOnly cookie
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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', '
|
|
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 {
|
package/src/types/index.ts
CHANGED
|
@@ -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;
|