@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.
- package/package.json +10 -9
- package/src/__tests__/auth-client-oauth-register.test.ts +264 -0
- package/src/__tests__/auth-functions.test.ts +190 -0
- 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 +120 -37
- package/src/client/use-auth.ts +19 -0
- package/src/server/middleware.ts +11 -7
- package/src/types/index.ts +6 -0
- package/src/utils/token.ts +14 -2
package/src/client/functions.ts
CHANGED
|
@@ -113,36 +113,40 @@ const REMEMBER_ME_KEY = 'auth_remember_me';
|
|
|
113
113
|
|
|
114
114
|
let _memToken: string | null = null;
|
|
115
115
|
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
124
|
+
return null;
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
function _isRememberMe(): boolean {
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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()
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
/**
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
}
|
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;
|
package/src/utils/token.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
75
|
+
const decoded = JSON.parse(_base64UrlDecode(payload));
|
|
64
76
|
|
|
65
77
|
if (typeof decoded !== 'object' || decoded === null) {
|
|
66
78
|
return null;
|