@startsimpli/auth 0.4.7 → 0.4.8
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
CHANGED
|
@@ -39,21 +39,6 @@ function makeJwt(payload: object): string {
|
|
|
39
39
|
const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, userId: '1' });
|
|
40
40
|
const REFRESHED_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200, userId: '1' });
|
|
41
41
|
|
|
42
|
-
// Stub the CSRF token helper so refreshAccessToken() doesn't fail
|
|
43
|
-
vi.mock('../utils/cookies', () => ({
|
|
44
|
-
getCsrfToken: () => 'test-csrf',
|
|
45
|
-
setCsrfToken: vi.fn(),
|
|
46
|
-
}));
|
|
47
|
-
|
|
48
|
-
// Stub fetchCsrfToken (called inside refreshAccessToken)
|
|
49
|
-
vi.mock('../client/functions', async (importOriginal) => {
|
|
50
|
-
const original = await importOriginal<typeof import('../client/functions')>();
|
|
51
|
-
return {
|
|
52
|
-
...original,
|
|
53
|
-
fetchCsrfToken: vi.fn().mockResolvedValue(undefined),
|
|
54
|
-
};
|
|
55
|
-
});
|
|
56
|
-
|
|
57
42
|
describe('authFetch — concurrent 401 refresh atomicity (uhxu)', () => {
|
|
58
43
|
beforeEach(() => {
|
|
59
44
|
vi.clearAllMocks();
|
|
@@ -72,6 +72,7 @@ const {
|
|
|
72
72
|
setAccessToken,
|
|
73
73
|
getAccessToken,
|
|
74
74
|
setOnSessionExpired,
|
|
75
|
+
notifySessionExpired,
|
|
75
76
|
setRememberMe,
|
|
76
77
|
} = await import('../client/functions');
|
|
77
78
|
|
|
@@ -212,6 +213,46 @@ describe('authFetch session expiration on unrecoverable 401', () => {
|
|
|
212
213
|
});
|
|
213
214
|
});
|
|
214
215
|
|
|
216
|
+
describe('notifySessionExpired cooldown', () => {
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
setOnSessionExpired(null);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(() => {
|
|
222
|
+
setOnSessionExpired(null);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('fires the registered callback at most once per cooldown window', () => {
|
|
226
|
+
const onExpired = vi.fn();
|
|
227
|
+
setOnSessionExpired(onExpired);
|
|
228
|
+
|
|
229
|
+
notifySessionExpired();
|
|
230
|
+
notifySessionExpired();
|
|
231
|
+
notifySessionExpired();
|
|
232
|
+
notifySessionExpired();
|
|
233
|
+
notifySessionExpired();
|
|
234
|
+
|
|
235
|
+
expect(onExpired).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('no-ops when no callback is registered', () => {
|
|
239
|
+
expect(() => notifySessionExpired()).not.toThrow();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('resets the cooldown when a new callback is registered', () => {
|
|
243
|
+
const first = vi.fn();
|
|
244
|
+
setOnSessionExpired(first);
|
|
245
|
+
notifySessionExpired();
|
|
246
|
+
expect(first).toHaveBeenCalledTimes(1);
|
|
247
|
+
|
|
248
|
+
// re-register: cooldown resets, so next notify should fire the new callback
|
|
249
|
+
const second = vi.fn();
|
|
250
|
+
setOnSessionExpired(second);
|
|
251
|
+
notifySessionExpired();
|
|
252
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
215
256
|
describe('signOut cookie cleanup', () => {
|
|
216
257
|
beforeEach(() => {
|
|
217
258
|
vi.clearAllMocks();
|
|
@@ -258,20 +299,41 @@ describe('refreshAccessToken clears session on failure', () => {
|
|
|
258
299
|
});
|
|
259
300
|
|
|
260
301
|
it('clears token when refresh endpoint returns non-ok', async () => {
|
|
261
|
-
mockFetch.mockImplementation((
|
|
262
|
-
|
|
263
|
-
return Promise.resolve({ ok: true });
|
|
264
|
-
}
|
|
265
|
-
return Promise.resolve({
|
|
302
|
+
mockFetch.mockImplementation(() =>
|
|
303
|
+
Promise.resolve({
|
|
266
304
|
ok: false,
|
|
267
305
|
status: 401,
|
|
268
306
|
json: async () => ({ detail: 'Token expired' }),
|
|
269
|
-
})
|
|
270
|
-
|
|
307
|
+
})
|
|
308
|
+
);
|
|
271
309
|
|
|
272
310
|
const result = await refreshAccessToken();
|
|
273
311
|
|
|
274
312
|
expect(result).toBeNull();
|
|
275
313
|
expect(getAccessToken()).toBeNull();
|
|
276
314
|
});
|
|
315
|
+
|
|
316
|
+
it('does not fetch the CSRF endpoint or send X-CSRFToken', async () => {
|
|
317
|
+
mockFetch.mockImplementation(() =>
|
|
318
|
+
Promise.resolve({
|
|
319
|
+
ok: true,
|
|
320
|
+
status: 200,
|
|
321
|
+
json: async () => ({ access: VALID_TOKEN }),
|
|
322
|
+
})
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
await refreshAccessToken();
|
|
326
|
+
|
|
327
|
+
const csrfCall = mockFetch.mock.calls.find(
|
|
328
|
+
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/csrf/')
|
|
329
|
+
);
|
|
330
|
+
expect(csrfCall).toBeUndefined();
|
|
331
|
+
|
|
332
|
+
const refreshCall = mockFetch.mock.calls.find(
|
|
333
|
+
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/auth/token/refresh/')
|
|
334
|
+
);
|
|
335
|
+
expect(refreshCall).toBeDefined();
|
|
336
|
+
const headers = refreshCall![1]?.headers;
|
|
337
|
+
expect(headers['X-CSRFToken']).toBeUndefined();
|
|
338
|
+
});
|
|
277
339
|
});
|
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) ---
|
|
@@ -431,43 +453,12 @@ export async function completeGoogleOAuth(code: string, state: string) {
|
|
|
431
453
|
return parsed;
|
|
432
454
|
}
|
|
433
455
|
|
|
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
|
-
}
|
|
451
|
-
}
|
|
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
|
-
}
|
|
456
456
|
|
|
457
457
|
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.');
|
|
462
|
-
setAccessToken(null);
|
|
463
|
-
return null;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
458
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
|
|
467
459
|
method: 'POST',
|
|
468
460
|
headers: {
|
|
469
461
|
'Content-Type': 'application/json',
|
|
470
|
-
'X-CSRFToken': csrfToken,
|
|
471
462
|
},
|
|
472
463
|
credentials: 'include',
|
|
473
464
|
});
|
|
@@ -512,7 +503,6 @@ export async function getMe(): Promise<AuthUser | null> {
|
|
|
512
503
|
}
|
|
513
504
|
|
|
514
505
|
export async function signOut(): Promise<void> {
|
|
515
|
-
const csrfToken = getCsrfToken();
|
|
516
506
|
const token = getAccessToken();
|
|
517
507
|
|
|
518
508
|
try {
|
|
@@ -520,7 +510,6 @@ export async function signOut(): Promise<void> {
|
|
|
520
510
|
method: 'POST',
|
|
521
511
|
headers: {
|
|
522
512
|
'Content-Type': 'application/json',
|
|
523
|
-
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
|
524
513
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
525
514
|
},
|
|
526
515
|
credentials: 'include',
|
|
@@ -583,7 +572,7 @@ export async function authFetch(
|
|
|
583
572
|
if (!refreshed || retryResponse.status === 401) {
|
|
584
573
|
// Refresh failed or retried request still unauthorized — session is dead.
|
|
585
574
|
setAccessToken(null);
|
|
586
|
-
|
|
575
|
+
notifySessionExpired();
|
|
587
576
|
}
|
|
588
577
|
|
|
589
578
|
return retryResponse;
|