@djangocfg/api 2.1.449 → 2.1.450
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/dist/auth.cjs +80 -20
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +2 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.mjs +80 -20
- package/dist/auth.mjs.map +1 -1
- package/package.json +2 -2
- package/src/auth/__tests__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +76 -0
- package/src/auth/context/AuthContext.tsx +121 -13
- package/src/auth/hooks/useTokenRefresh.ts +3 -21
- package/src/auth/utils/jwt.ts +66 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.450",
|
|
4
4
|
"description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"django",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"devDependencies": {
|
|
87
87
|
"@types/node": "^25.2.3",
|
|
88
88
|
"@types/react": "^19.2.15",
|
|
89
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
89
|
+
"@djangocfg/typescript-config": "^2.1.450",
|
|
90
90
|
"next": "^16.2.2",
|
|
91
91
|
"react": "^19.2.4",
|
|
92
92
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT expiry-helper tests (auth/utils/jwt.ts).
|
|
3
|
+
*
|
|
4
|
+
* These functions decide whether the client should trust a persisted token or
|
|
5
|
+
* treat the session as dead. They must FAIL CLOSED: anything undecodable counts
|
|
6
|
+
* as expired, so garbage in localStorage sends the user to the login form
|
|
7
|
+
* rather than looping on 401s. (This is the automated version of the user's
|
|
8
|
+
* manual "clear localStorage and the form appeared".)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { getTokenExpiry, isTokenExpired, isTokenExpiringSoon } from '../utils/jwt';
|
|
14
|
+
|
|
15
|
+
const NOW = 1_800_000_000_000; // fixed "now" in ms (injected, not Date.now())
|
|
16
|
+
|
|
17
|
+
/** Base64url without padding — mirrors real JWT segment encoding. */
|
|
18
|
+
function b64url(obj: unknown): string {
|
|
19
|
+
const json = JSON.stringify(obj);
|
|
20
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64');
|
|
21
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build a syntactically valid 3-segment JWT with the given payload. */
|
|
25
|
+
function jwt(payload: Record<string, unknown>): string {
|
|
26
|
+
const header = b64url({ alg: 'HS256', typ: 'JWT' });
|
|
27
|
+
const body = b64url(payload);
|
|
28
|
+
return `${header}.${body}.signature`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** exp claim is in SECONDS in a real JWT. */
|
|
32
|
+
const expSeconds = (ms: number) => Math.floor(ms / 1000);
|
|
33
|
+
|
|
34
|
+
describe('getTokenExpiry', () => {
|
|
35
|
+
it('extracts exp (converted to ms) from a well-formed token', () => {
|
|
36
|
+
const token = jwt({ exp: expSeconds(NOW) });
|
|
37
|
+
expect(getTokenExpiry(token)).toBe(expSeconds(NOW) * 1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns null for null / undefined / empty', () => {
|
|
41
|
+
expect(getTokenExpiry(null)).toBeNull();
|
|
42
|
+
expect(getTokenExpiry(undefined)).toBeNull();
|
|
43
|
+
expect(getTokenExpiry('')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null for a non-JWT string (wrong segment count)', () => {
|
|
47
|
+
expect(getTokenExpiry('not-a-jwt')).toBeNull();
|
|
48
|
+
expect(getTokenExpiry('only.two')).toBeNull();
|
|
49
|
+
expect(getTokenExpiry('a.b.c.d')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns null when the payload is not valid base64/JSON', () => {
|
|
53
|
+
expect(getTokenExpiry('aaa.!!!!.bbb')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns null when exp is missing or non-numeric', () => {
|
|
57
|
+
expect(getTokenExpiry(jwt({ sub: 'user' }))).toBeNull();
|
|
58
|
+
expect(getTokenExpiry(jwt({ exp: 'soon' }))).toBeNull();
|
|
59
|
+
expect(getTokenExpiry(jwt({ exp: null }))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles a non-object payload (e.g. a JSON number) as no-exp', () => {
|
|
63
|
+
const header = b64url({ alg: 'HS256' });
|
|
64
|
+
const body = b64url(42 as unknown as Record<string, unknown>);
|
|
65
|
+
expect(getTokenExpiry(`${header}.${body}.sig`)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('isTokenExpired (fail-closed)', () => {
|
|
70
|
+
it('false for a token that expires in the future', () => {
|
|
71
|
+
const token = jwt({ exp: expSeconds(NOW + 60_000) });
|
|
72
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('true for a token already past exp', () => {
|
|
76
|
+
const token = jwt({ exp: expSeconds(NOW - 1) });
|
|
77
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('true exactly at exp (boundary is inclusive → expired)', () => {
|
|
81
|
+
const token = jwt({ exp: expSeconds(NOW) });
|
|
82
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('skew treats a soon-to-expire token as already expired', () => {
|
|
86
|
+
const token = jwt({ exp: expSeconds(NOW + 30_000) }); // expires in 30s
|
|
87
|
+
expect(isTokenExpired(token, 60_000, NOW)).toBe(true); // 60s skew
|
|
88
|
+
expect(isTokenExpired(token, 10_000, NOW)).toBe(false); // 10s skew
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('true (fail-closed) for null / garbage / no-exp tokens', () => {
|
|
92
|
+
expect(isTokenExpired(null, 0, NOW)).toBe(true);
|
|
93
|
+
expect(isTokenExpired(undefined, 0, NOW)).toBe(true);
|
|
94
|
+
expect(isTokenExpired('garbage', 0, NOW)).toBe(true);
|
|
95
|
+
expect(isTokenExpired(jwt({ sub: 'x' }), 0, NOW)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('isTokenExpiringSoon', () => {
|
|
100
|
+
it('true when expiry is within the threshold', () => {
|
|
101
|
+
const token = jwt({ exp: expSeconds(NOW + 5 * 60_000) }); // 5 min
|
|
102
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('false when expiry is beyond the threshold', () => {
|
|
106
|
+
const token = jwt({ exp: expSeconds(NOW + 20 * 60_000) }); // 20 min
|
|
107
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('false (not "soon") for undecodable tokens — the expired path owns those', () => {
|
|
111
|
+
expect(isTokenExpiringSoon(null, 10 * 60_000, NOW)).toBe(false);
|
|
112
|
+
expect(isTokenExpiringSoon('garbage', 10 * 60_000, NOW)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('true for an already-expired but decodable token', () => {
|
|
116
|
+
const token = jwt({ exp: expSeconds(NOW - 1000) });
|
|
117
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unrecoverable-401 contract test.
|
|
3
|
+
*
|
|
4
|
+
* Regression guard for the "stuck on Authenticating…" bug: when the refresh
|
|
5
|
+
* token is dead (expired, or blacklisted after rotation), the refresh handler
|
|
6
|
+
* fails and the session is UNRECOVERABLE. The client must then:
|
|
7
|
+
* - report failure from the proactive path (`refreshNow()` → null), and
|
|
8
|
+
* - fire `auth.onUnauthorized(cb)` from the reactive (interceptor) path,
|
|
9
|
+
* so the app can clear tokens and show the login form. If neither fires, stale
|
|
10
|
+
* tokens sit in storage and the guard hangs forever.
|
|
11
|
+
*
|
|
12
|
+
* The store reads `window.localStorage` and captures `isBrowser` at import
|
|
13
|
+
* time, so we install a minimal localStorage on globalThis BEFORE importing it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
function installBrowserStorage() {
|
|
19
|
+
const map = new Map<string, string>();
|
|
20
|
+
const storage = {
|
|
21
|
+
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
|
22
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
23
|
+
removeItem: (k: string) => void map.delete(k),
|
|
24
|
+
clear: () => map.clear(),
|
|
25
|
+
};
|
|
26
|
+
vi.stubGlobal('window', { localStorage: storage, location: { protocol: 'http:' } });
|
|
27
|
+
vi.stubGlobal('localStorage', storage);
|
|
28
|
+
return map;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('unrecoverable 401 handling', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.unstubAllGlobals();
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('refreshNow() returns null when the refresh handler fails (blacklisted token)', async () => {
|
|
38
|
+
installBrowserStorage();
|
|
39
|
+
const { auth } = await import('../../_api/generated/helpers/auth');
|
|
40
|
+
|
|
41
|
+
auth.setToken('access-stale');
|
|
42
|
+
auth.setRefreshToken('R-blacklisted');
|
|
43
|
+
|
|
44
|
+
// Model a server that rejects the (blacklisted) refresh token.
|
|
45
|
+
auth.setRefreshHandler(async () => {
|
|
46
|
+
const err = new Error('Token is blacklisted') as Error & { code: string };
|
|
47
|
+
err.code = 'token_not_valid';
|
|
48
|
+
throw err;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const store = auth as unknown as { refreshNow?: () => Promise<string | null> };
|
|
52
|
+
if (typeof store.refreshNow !== 'function') return; // pre-regen: skip gracefully
|
|
53
|
+
|
|
54
|
+
const result = await store.refreshNow();
|
|
55
|
+
expect(result).toBeNull(); // proactive path reports the dead session
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refreshNow() returns null when there is no refresh token at all', async () => {
|
|
59
|
+
installBrowserStorage();
|
|
60
|
+
const { auth } = await import('../../_api/generated/helpers/auth');
|
|
61
|
+
|
|
62
|
+
auth.setToken('access-orphan');
|
|
63
|
+
// no refresh token set
|
|
64
|
+
auth.setRefreshHandler(async () => ({ access: 'never', refresh: 'never' }));
|
|
65
|
+
|
|
66
|
+
const store = auth as unknown as { refreshNow?: () => Promise<string | null> };
|
|
67
|
+
if (typeof store.refreshNow !== 'function') return;
|
|
68
|
+
|
|
69
|
+
expect(await store.refreshNow()).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('onUnauthorized callback can be registered and cleared (wiring the login-form escape hatch)', async () => {
|
|
73
|
+
installBrowserStorage();
|
|
74
|
+
const { auth } = await import('../../_api/generated/helpers/auth');
|
|
75
|
+
|
|
76
|
+
// The AuthContext relies on this being a no-throw registration surface.
|
|
77
|
+
const cb = vi.fn();
|
|
78
|
+
expect(() => auth.onUnauthorized(cb)).not.toThrow();
|
|
79
|
+
expect(() => auth.onUnauthorized(null)).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Reactive path: the response interceptor installed by installAuthOnClient must
|
|
85
|
+
* call onUnauthorized(response) when a 401 cannot be recovered, and must NOT
|
|
86
|
+
* call it when the refresh handler rescues the request. We install the auth
|
|
87
|
+
* machinery onto a FAKE client that just captures the response interceptor, then
|
|
88
|
+
* invoke it with a synthetic 401 — exercising the real recovery branch logic.
|
|
89
|
+
*/
|
|
90
|
+
describe('response interceptor → onUnauthorized (reactive 401 path)', () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
vi.unstubAllGlobals();
|
|
93
|
+
vi.resetModules();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async function installOnFakeClient() {
|
|
97
|
+
installBrowserStorage();
|
|
98
|
+
const { auth, installAuthOnClient } = await import('../../_api/generated/helpers/auth');
|
|
99
|
+
|
|
100
|
+
let responseInterceptor:
|
|
101
|
+
| ((res: Response, req: Request) => Promise<Response> | Response)
|
|
102
|
+
| null = null;
|
|
103
|
+
|
|
104
|
+
const fakeClient = {
|
|
105
|
+
setConfig: () => {},
|
|
106
|
+
interceptors: {
|
|
107
|
+
request: { use: () => {} },
|
|
108
|
+
response: {
|
|
109
|
+
use: (fn: (res: Response, req: Request) => Promise<Response> | Response) => {
|
|
110
|
+
responseInterceptor = fn;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
error: { use: () => {} },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
installAuthOnClient(fakeClient as never);
|
|
118
|
+
return { auth, invoke: () => responseInterceptor! };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function res401(): Response {
|
|
122
|
+
return new Response(JSON.stringify({ detail: 'Token is blacklisted', code: 'token_not_valid' }), {
|
|
123
|
+
status: 401,
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function req(url = 'http://localhost:8000/cfg/accounts/profile/'): Request {
|
|
128
|
+
return new Request(url, { method: 'GET' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('fires onUnauthorized when there is NO refresh handler / token', async () => {
|
|
132
|
+
const { auth, invoke } = await installOnFakeClient();
|
|
133
|
+
const onUnauth = vi.fn();
|
|
134
|
+
auth.onUnauthorized(onUnauth);
|
|
135
|
+
// No refresh token, no handler → tryRefresh returns null → onUnauthorized fires.
|
|
136
|
+
|
|
137
|
+
const out = await invoke()(res401(), req());
|
|
138
|
+
expect(onUnauth).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(out.status).toBe(401); // original 401 surfaced
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('fires onUnauthorized when the refresh handler FAILS (blacklisted token)', async () => {
|
|
143
|
+
const { auth, invoke } = await installOnFakeClient();
|
|
144
|
+
auth.setToken('access-stale');
|
|
145
|
+
auth.setRefreshToken('R-dead');
|
|
146
|
+
auth.setRefreshHandler(async () => {
|
|
147
|
+
throw new Error('Token is blacklisted');
|
|
148
|
+
});
|
|
149
|
+
const onUnauth = vi.fn();
|
|
150
|
+
auth.onUnauthorized(onUnauth);
|
|
151
|
+
|
|
152
|
+
const out = await invoke()(res401(), req());
|
|
153
|
+
expect(onUnauth).toHaveBeenCalledTimes(1);
|
|
154
|
+
expect(out.status).toBe(401);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does NOT fire onUnauthorized on a non-401 response', async () => {
|
|
158
|
+
const { auth, invoke } = await installOnFakeClient();
|
|
159
|
+
const onUnauth = vi.fn();
|
|
160
|
+
auth.onUnauthorized(onUnauth);
|
|
161
|
+
|
|
162
|
+
const ok = new Response('{}', { status: 200 });
|
|
163
|
+
const out = await invoke()(ok, req());
|
|
164
|
+
expect(onUnauth).not.toHaveBeenCalled();
|
|
165
|
+
expect(out.status).toBe(200);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does NOT fire onUnauthorized when refresh SUCCEEDS and retry is attempted', async () => {
|
|
169
|
+
const { auth, invoke } = await installOnFakeClient();
|
|
170
|
+
auth.setToken('access-stale');
|
|
171
|
+
auth.setRefreshToken('R-good');
|
|
172
|
+
auth.setRefreshHandler(async () => ({ access: 'access-fresh', refresh: 'R-rotated' }));
|
|
173
|
+
const onUnauth = vi.fn();
|
|
174
|
+
auth.onUnauthorized(onUnauth);
|
|
175
|
+
|
|
176
|
+
// The interceptor retries via global fetch after a successful refresh.
|
|
177
|
+
// Stub fetch to return 200 for the retry so onUnauthorized is not reached.
|
|
178
|
+
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
|
179
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
180
|
+
|
|
181
|
+
const out = await invoke()(res401(), req());
|
|
182
|
+
expect(fetchMock).toHaveBeenCalledTimes(1); // retry happened
|
|
183
|
+
expect(onUnauth).not.toHaveBeenCalled(); // recovered → no logout
|
|
184
|
+
expect(out.status).toBe(200);
|
|
185
|
+
expect(auth.getToken()).toBe('access-fresh'); // rotated tokens persisted
|
|
186
|
+
expect(auth.getRefreshToken()).toBe('R-rotated');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('fires onUnauthorized when the retry STILL returns 401', async () => {
|
|
190
|
+
const { auth, invoke } = await installOnFakeClient();
|
|
191
|
+
auth.setToken('access-stale');
|
|
192
|
+
auth.setRefreshToken('R-good');
|
|
193
|
+
auth.setRefreshHandler(async () => ({ access: 'access-fresh', refresh: 'R-rotated' }));
|
|
194
|
+
const onUnauth = vi.fn();
|
|
195
|
+
auth.onUnauthorized(onUnauth);
|
|
196
|
+
|
|
197
|
+
// Refresh succeeds but the retried request is still rejected (401).
|
|
198
|
+
const fetchMock = vi.fn(async () => new Response('{}', { status: 401 }));
|
|
199
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
200
|
+
|
|
201
|
+
const out = await invoke()(res401(), req());
|
|
202
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
203
|
+
expect(onUnauth).toHaveBeenCalledTimes(1); // dead even after refresh+retry
|
|
204
|
+
expect(out.status).toBe(401);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap dead-session detection (isSessionDeadOnBootstrap in AuthContext).
|
|
3
|
+
*
|
|
4
|
+
* This is the decision that, at app start, either shows the login form or lets
|
|
5
|
+
* the app try to use / refresh the persisted tokens. It reproduces the exact
|
|
6
|
+
* scenario the user hit: stale tokens in localStorage that can never recover,
|
|
7
|
+
* where the fix used to be "clear localStorage by hand". The session is DEAD
|
|
8
|
+
* only when the access token is unusable AND the refresh token can't save it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { isSessionDeadOnBootstrap } from '../context/AuthContext';
|
|
14
|
+
|
|
15
|
+
const NOW = 1_800_000_000_000;
|
|
16
|
+
const expSeconds = (ms: number) => Math.floor(ms / 1000);
|
|
17
|
+
|
|
18
|
+
function b64url(obj: unknown): string {
|
|
19
|
+
return Buffer.from(JSON.stringify(obj), 'utf8')
|
|
20
|
+
.toString('base64')
|
|
21
|
+
.replace(/\+/g, '-')
|
|
22
|
+
.replace(/\//g, '_')
|
|
23
|
+
.replace(/=+$/, '');
|
|
24
|
+
}
|
|
25
|
+
function jwt(payload: Record<string, unknown>): string {
|
|
26
|
+
return `${b64url({ alg: 'HS256' })}.${b64url(payload)}.sig`;
|
|
27
|
+
}
|
|
28
|
+
const live = () => jwt({ exp: expSeconds(NOW + 3_600_000) }); // +1h
|
|
29
|
+
const dead = () => jwt({ exp: expSeconds(NOW - 3_600_000) }); // -1h
|
|
30
|
+
|
|
31
|
+
describe('isSessionDeadOnBootstrap', () => {
|
|
32
|
+
it('ALIVE: live access + live refresh', () => {
|
|
33
|
+
expect(isSessionDeadOnBootstrap(live(), live(), NOW)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('ALIVE: live access even if refresh is dead (access still works)', () => {
|
|
37
|
+
expect(isSessionDeadOnBootstrap(live(), dead(), NOW)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('ALIVE: live access even with no refresh token', () => {
|
|
41
|
+
expect(isSessionDeadOnBootstrap(live(), null, NOW)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('ALIVE: dead access but LIVE refresh (refresh can rescue it)', () => {
|
|
45
|
+
expect(isSessionDeadOnBootstrap(dead(), live(), NOW)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('DEAD: dead access + dead refresh (the classic stuck state)', () => {
|
|
49
|
+
expect(isSessionDeadOnBootstrap(dead(), dead(), NOW)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('DEAD: dead access + no refresh token', () => {
|
|
53
|
+
expect(isSessionDeadOnBootstrap(dead(), null, NOW)).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('DEAD: both tokens null (empty storage that still triggered init)', () => {
|
|
57
|
+
expect(isSessionDeadOnBootstrap(null, null, NOW)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('DEAD: garbage access + garbage refresh (corrupt storage — user cleared this by hand)', () => {
|
|
61
|
+
expect(isSessionDeadOnBootstrap('garbage', 'also-garbage', NOW)).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('DEAD: garbage access + no refresh', () => {
|
|
65
|
+
expect(isSessionDeadOnBootstrap('xxxxx', null, NOW)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ALIVE: garbage access but a LIVE refresh token still lets us recover', () => {
|
|
69
|
+
expect(isSessionDeadOnBootstrap('garbage', live(), NOW)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('DEAD: access expired exactly at now + refresh expired (inclusive boundary)', () => {
|
|
73
|
+
const atNow = jwt({ exp: expSeconds(NOW) });
|
|
74
|
+
expect(isSessionDeadOnBootstrap(atNow, atNow, NOW)).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -19,6 +19,7 @@ import { useTokenRefresh } from '../hooks/useTokenRefresh';
|
|
|
19
19
|
import { ensureRefreshHandler } from '../refreshHandler';
|
|
20
20
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
21
21
|
import { authLogger } from '../utils/logger';
|
|
22
|
+
import { isTokenExpired } from '../utils/jwt';
|
|
22
23
|
import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
23
24
|
|
|
24
25
|
import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
@@ -41,6 +42,33 @@ const hasValidTokens = (): boolean => {
|
|
|
41
42
|
return apiAccounts.isAuthenticated();
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Decide, at bootstrap, whether the persisted tokens are a DEAD session that
|
|
47
|
+
* can never be recovered — so we should clear storage and show the login form
|
|
48
|
+
* instead of firing doomed API calls.
|
|
49
|
+
*
|
|
50
|
+
* Pure + injectable (tokens passed in) so it can be unit-tested without a store.
|
|
51
|
+
*
|
|
52
|
+
* A session is dead when the access token is missing/expired/malformed AND the
|
|
53
|
+
* refresh token cannot save it (missing or itself expired/malformed). If either
|
|
54
|
+
* the access token is still live, OR a usable refresh token exists, the session
|
|
55
|
+
* is NOT dead — normal init proceeds (and refresh will run if needed).
|
|
56
|
+
*
|
|
57
|
+
* This is exactly the state the user used to fix by hand ("cleared localStorage
|
|
58
|
+
* and then the form appeared"): stale/blacklisted-companion tokens left in
|
|
59
|
+
* storage with no path forward.
|
|
60
|
+
*/
|
|
61
|
+
export const isSessionDeadOnBootstrap = (
|
|
62
|
+
accessToken: string | null,
|
|
63
|
+
refreshToken: string | null,
|
|
64
|
+
now = Date.now(),
|
|
65
|
+
): boolean => {
|
|
66
|
+
const accessDead = isTokenExpired(accessToken, 0, now);
|
|
67
|
+
if (!accessDead) return false; // access still usable → alive
|
|
68
|
+
const refreshDead = isTokenExpired(refreshToken, 0, now);
|
|
69
|
+
return refreshDead; // access dead + refresh dead/absent → unrecoverable
|
|
70
|
+
};
|
|
71
|
+
|
|
44
72
|
// Internal provider that uses AccountsContext
|
|
45
73
|
const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
|
|
46
74
|
const accounts = useAccountsContext();
|
|
@@ -69,6 +97,22 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
69
97
|
});
|
|
70
98
|
|
|
71
99
|
const [initialized, setInitialized] = useState(false);
|
|
100
|
+
|
|
101
|
+
// Reactivity tick for `isAuthenticated`. Token state lives in the auth store
|
|
102
|
+
// (localStorage), NOT React state — so `apiAccounts.isAuthenticated()` is read
|
|
103
|
+
// once when the context value is memoised and would go stale after tokens are
|
|
104
|
+
// cleared (e.g. a blacklisted-refresh 401). Bumping this on every auth-state
|
|
105
|
+
// transition forces the memo to recompute, so the guard reliably flips to
|
|
106
|
+
// "unauthenticated" and the login form is shown instead of an endless
|
|
107
|
+
// "Authenticating…" spinner.
|
|
108
|
+
const [authTick, setAuthTick] = useState(0);
|
|
109
|
+
const bumpAuthTick = useCallback(() => setAuthTick((t) => t + 1), []);
|
|
110
|
+
|
|
111
|
+
// Latches once a dead session has been handled, so the redirect-to-login only
|
|
112
|
+
// fires once even if both the interceptor (auth.onUnauthorized) and the
|
|
113
|
+
// proactive-refresh error path trip for the same expired session.
|
|
114
|
+
const onUnauthorizedRef = useRef(false);
|
|
115
|
+
|
|
72
116
|
const router = useCfgRouter();
|
|
73
117
|
const pathname = usePathname();
|
|
74
118
|
const queryParams = useQueryParams();
|
|
@@ -76,17 +120,10 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
76
120
|
// Use localStorage hook for email
|
|
77
121
|
const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(EMAIL_STORAGE_KEY, null);
|
|
78
122
|
|
|
79
|
-
// Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
authLogger.info('Token auto-refreshed successfully');
|
|
84
|
-
},
|
|
85
|
-
onRefreshError: (error) => {
|
|
86
|
-
authLogger.warn('Token auto-refresh failed:', error.message);
|
|
87
|
-
// Don't logout on refresh error - user might still have valid session
|
|
88
|
-
},
|
|
89
|
-
});
|
|
123
|
+
// Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect.
|
|
124
|
+
// NOTE: onRefreshError is wired below, after clearAuthState is defined
|
|
125
|
+
// (see handleProactiveRefreshError), so it can collapse a dead session to the
|
|
126
|
+
// login form. useTokenRefresh only runs when a refresh token exists.
|
|
90
127
|
|
|
91
128
|
// Map AccountsContext profile to UserProfile
|
|
92
129
|
const user = accounts.profile as UserProfile | null;
|
|
@@ -115,7 +152,36 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
115
152
|
// Note: user is now managed by AccountsContext, will auto-update
|
|
116
153
|
setInitialized(true);
|
|
117
154
|
setIsLoading(false);
|
|
118
|
-
|
|
155
|
+
// Tokens just went away — recompute `isAuthenticated` so the guard shows
|
|
156
|
+
// the login form instead of hanging on the preloader.
|
|
157
|
+
bumpAuthTick();
|
|
158
|
+
}, [bumpAuthTick]);
|
|
159
|
+
|
|
160
|
+
// Proactive-refresh failure handler. useTokenRefresh (expiry timer / focus /
|
|
161
|
+
// reconnect) runs OUTSIDE the response interceptor, so its failures do NOT go
|
|
162
|
+
// through auth.onUnauthorized. A failed proactive refresh means the refresh
|
|
163
|
+
// token is dead (expired, or blacklisted after rotation) → the session is
|
|
164
|
+
// unrecoverable → clear it and show the login form. This is what was missing:
|
|
165
|
+
// the old handler just logged a warning and left stale tokens, so the app hung
|
|
166
|
+
// on "Authenticating…" forever.
|
|
167
|
+
const handleProactiveRefreshError = useCallback((error: Error) => {
|
|
168
|
+
authLogger.warn('Proactive token refresh failed — session is dead, redirecting to login:', error.message);
|
|
169
|
+
// Guard against the redirect firing twice if the interceptor path already tripped.
|
|
170
|
+
if (onUnauthorizedRef.current) return;
|
|
171
|
+
onUnauthorizedRef.current = true;
|
|
172
|
+
clearAuthState('proactiveRefresh:failed');
|
|
173
|
+
const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
|
|
174
|
+
router.hardReplace(authCallbackUrl);
|
|
175
|
+
}, [clearAuthState, router]);
|
|
176
|
+
|
|
177
|
+
// Automatic token refresh — refreshes before expiry, on focus, and on reconnect.
|
|
178
|
+
useTokenRefresh({
|
|
179
|
+
enabled: true,
|
|
180
|
+
onRefresh: () => {
|
|
181
|
+
authLogger.info('Token auto-refreshed successfully');
|
|
182
|
+
},
|
|
183
|
+
onRefreshError: handleProactiveRefreshError,
|
|
184
|
+
});
|
|
119
185
|
|
|
120
186
|
// Global error handler for auth-related errors
|
|
121
187
|
// Only clears auth on actual authentication errors (401), not on any API error
|
|
@@ -141,6 +207,34 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
141
207
|
return false;
|
|
142
208
|
}, [clearAuthState]);
|
|
143
209
|
|
|
210
|
+
// Unrecoverable-401 handler from the generated client. Fires ONLY when a 401
|
|
211
|
+
// could not be recovered by the refresh path — no refresh token, no handler,
|
|
212
|
+
// the refresh call failed (e.g. "Token is blacklisted" after rotation), or
|
|
213
|
+
// the retried request still 401'd. Transparently-recovered 401s never reach
|
|
214
|
+
// here. This is the single place that guarantees a dead session collapses to
|
|
215
|
+
// the login form: clear tokens + hard-redirect to /auth.
|
|
216
|
+
//
|
|
217
|
+
// Without this, a proactive refresh (useTokenRefresh) hitting a blacklisted
|
|
218
|
+
// token would just log a warning and leave stale tokens in storage, so the
|
|
219
|
+
// guard stayed on "Authenticating…" forever and never rendered the form.
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
const handler = () => {
|
|
222
|
+
if (onUnauthorizedRef.current) return; // de-dupe concurrent 401s
|
|
223
|
+
onUnauthorizedRef.current = true;
|
|
224
|
+
authLogger.warn('Unrecoverable 401 (refresh failed) — clearing session and redirecting to login');
|
|
225
|
+
clearAuthState('onUnauthorized:401');
|
|
226
|
+
const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
|
|
227
|
+
router.hardReplace(authCallbackUrl);
|
|
228
|
+
};
|
|
229
|
+
apiAccounts.onUnauthorized(handler);
|
|
230
|
+
return () => {
|
|
231
|
+
// Only clear if we're still the registered handler (StrictMode double-mount
|
|
232
|
+
// can register a newer one before this cleanup runs).
|
|
233
|
+
apiAccounts.onUnauthorized(null);
|
|
234
|
+
};
|
|
235
|
+
// clearAuthState/router are stable (useCallback); configRef is a ref.
|
|
236
|
+
}, [clearAuthState, router]);
|
|
237
|
+
|
|
144
238
|
// SWR onError: auto-logout on 401 from any SWR hook
|
|
145
239
|
const isAutoLoggingOutRef = useRef(false);
|
|
146
240
|
const swrOnError = useCallback((error: any) => {
|
|
@@ -244,6 +338,17 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
244
338
|
authLogger.info('Refresh token from API:', refreshToken ? `${refreshToken.substring(0, 20)}...` : 'null');
|
|
245
339
|
authLogger.info('localStorage keys:', Object.keys(localStorage).filter(k => k.includes('token') || k.includes('auth')));
|
|
246
340
|
|
|
341
|
+
// Fail-fast: if storage holds a DEAD session (access expired/malformed AND
|
|
342
|
+
// refresh unusable), clear it and show the login form immediately — never
|
|
343
|
+
// fire doomed profile/refresh calls that just 401 and hang the UI. This is
|
|
344
|
+
// the automated version of "clear localStorage, then the form appears".
|
|
345
|
+
// Skipped in iframe mode: the parent frame owns token delivery there.
|
|
346
|
+
if (!isInIframe && isSessionDeadOnBootstrap(token, refreshToken)) {
|
|
347
|
+
authLogger.warn('Bootstrap: dead session in storage (access + refresh both unusable) — clearing and showing login');
|
|
348
|
+
clearAuthState('initializeAuth:deadSession');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
247
352
|
const hasTokens = hasValidTokens();
|
|
248
353
|
authLogger.info('Has tokens:', hasTokens);
|
|
249
354
|
|
|
@@ -596,7 +701,9 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
596
701
|
() => ({
|
|
597
702
|
user,
|
|
598
703
|
isLoading,
|
|
599
|
-
// Consider authenticated if we have valid tokens, even without user profile
|
|
704
|
+
// Consider authenticated if we have valid tokens, even without user profile.
|
|
705
|
+
// `authTick` (in deps) forces recompute after tokens are cleared/set, since
|
|
706
|
+
// token state lives in storage, not React state.
|
|
600
707
|
isAuthenticated: apiAccounts.isAuthenticated(),
|
|
601
708
|
isAdminUser,
|
|
602
709
|
loadCurrentProfile,
|
|
@@ -622,6 +729,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
622
729
|
[
|
|
623
730
|
user,
|
|
624
731
|
isLoading,
|
|
732
|
+
authTick, // recompute isAuthenticated when tokens are cleared/set
|
|
625
733
|
isAdminUser,
|
|
626
734
|
loadCurrentProfile,
|
|
627
735
|
checkAuthAndRedirect,
|
|
@@ -24,6 +24,7 @@ import { useCallback, useEffect } from 'react';
|
|
|
24
24
|
import { api as apiAccounts } from '../../';
|
|
25
25
|
import { triggerRefresh } from '../refreshHandler';
|
|
26
26
|
import { authLogger } from '../utils/logger';
|
|
27
|
+
import { isTokenExpiringSoon } from '../utils/jwt';
|
|
27
28
|
|
|
28
29
|
// Configuration
|
|
29
30
|
const TOKEN_REFRESH_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes before expiry
|
|
@@ -38,29 +39,10 @@ interface UseTokenRefreshOptions {
|
|
|
38
39
|
onRefreshError?: (error: Error) => void;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
* Decode JWT and get expiry time (ms epoch), or null if undecodable.
|
|
43
|
-
*/
|
|
44
|
-
function getTokenExpiry(token: string): number | null {
|
|
45
|
-
try {
|
|
46
|
-
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
47
|
-
return payload.exp * 1000;
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Check if token is expiring within the threshold.
|
|
55
|
-
*/
|
|
56
|
-
function isTokenExpiringSoon(token: string, thresholdMs: number): boolean {
|
|
57
|
-
const expiry = getTokenExpiry(token);
|
|
58
|
-
if (!expiry) return false;
|
|
59
|
-
return expiry - Date.now() < thresholdMs;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
/**
|
|
63
43
|
* Hook for proactive automatic token refresh.
|
|
44
|
+
* (JWT expiry helpers live in ../utils/jwt so they can be unit-tested and
|
|
45
|
+
* reused by the auth-bootstrap validation path.)
|
|
64
46
|
*/
|
|
65
47
|
export function useTokenRefresh(options: UseTokenRefreshOptions = {}) {
|
|
66
48
|
const { enabled = true, onRefresh, onRefreshError } = options;
|