@djangocfg/api 2.1.448 → 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-server.cjs +1 -2272
- package/dist/auth-server.cjs.map +1 -1
- package/dist/auth-server.d.cts +1 -35
- package/dist/auth-server.d.ts +1 -35
- package/dist/auth-server.mjs +1 -2272
- package/dist/auth-server.mjs.map +1 -1
- package/dist/auth.cjs +285 -146
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +137 -2
- package/dist/auth.d.ts +137 -2
- package/dist/auth.mjs +294 -155
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +35 -0
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +147 -2
- package/dist/clients.d.ts +147 -2
- package/dist/clients.mjs +35 -0
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +11 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +11 -0
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +35 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +169 -6
- package/dist/index.d.ts +169 -6
- package/dist/index.mjs +35 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/_api/generated/_cfg_accounts/api.ts +8 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +84 -5
- package/src/_api/generated/_cfg_accounts/schemas/OAuthTokenResponse.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OTPRequestResponse.ts +2 -0
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLink.ts +15 -0
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLinkProviderEnum.ts +9 -0
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
- package/src/_api/generated/_cfg_centrifugo/api.ts +8 -0
- package/src/_api/generated/_cfg_totp/api.ts +8 -0
- package/src/_api/generated/helpers/auth.ts +12 -0
- package/src/_api/generated/openapi.json +84 -5
- package/src/_api/generated/types.gen.ts +131 -2
- package/src/auth/__tests__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
- package/src/auth/__tests__/refreshRotation.test.ts +119 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +76 -0
- package/src/auth/context/AuthContext.tsx +131 -14
- package/src/auth/hooks/useAuthForm.ts +5 -2
- package/src/auth/hooks/useAuthFormState.ts +4 -1
- package/src/auth/hooks/useTokenRefresh.ts +28 -62
- package/src/auth/middlewares/index.ts +7 -7
- package/src/auth/refreshHandler.ts +79 -0
- package/src/auth/types/form.ts +10 -0
- package/src/auth/types/index.ts +1 -0
- package/src/auth/utils/jwt.ts +66 -0
- package/src/auth/middlewares/tokenRefresh.ts +0 -161
|
@@ -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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refresh-token ROTATION contract test.
|
|
3
|
+
*
|
|
4
|
+
* This is the regression guard for the "Token is blacklisted" 401 that logged
|
|
5
|
+
* users out. Django (SimpleJWT via django-cfg) ships ROTATE_REFRESH_TOKENS +
|
|
6
|
+
* BLACKLIST_AFTER_ROTATION on by default: every successful refresh returns a
|
|
7
|
+
* NEW refresh token and blacklists the old one. If the client persists the OLD
|
|
8
|
+
* token instead of the rotated one, the *next* refresh reuses a blacklisted
|
|
9
|
+
* token and 401s.
|
|
10
|
+
*
|
|
11
|
+
* The test drives the real `auth` store with a handler that models exactly that
|
|
12
|
+
* server behaviour (rotate + blacklist), then asserts a SECOND refresh still
|
|
13
|
+
* succeeds — which can only happen if the store persisted the rotated token.
|
|
14
|
+
* A client that saves the old token (the original bug) fails the second refresh.
|
|
15
|
+
*
|
|
16
|
+
* The store reads `window.localStorage` and captures `isBrowser` at import time,
|
|
17
|
+
* so we install a minimal localStorage on globalThis BEFORE importing it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
21
|
+
|
|
22
|
+
// Minimal in-memory localStorage installed before the store module loads.
|
|
23
|
+
function installBrowserStorage() {
|
|
24
|
+
const map = new Map<string, string>();
|
|
25
|
+
const storage = {
|
|
26
|
+
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
|
27
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
28
|
+
removeItem: (k: string) => void map.delete(k),
|
|
29
|
+
clear: () => map.clear(),
|
|
30
|
+
};
|
|
31
|
+
vi.stubGlobal('window', { localStorage: storage, location: { protocol: 'http:' } });
|
|
32
|
+
vi.stubGlobal('localStorage', storage);
|
|
33
|
+
return map;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fake SimpleJWT refresh endpoint with rotation + blacklist.
|
|
38
|
+
* Each call: rejects a blacklisted token, otherwise blacklists the presented
|
|
39
|
+
* token and issues a fresh access + fresh (rotated) refresh.
|
|
40
|
+
*/
|
|
41
|
+
function makeRotatingServer() {
|
|
42
|
+
const blacklist = new Set<string>();
|
|
43
|
+
let seq = 0;
|
|
44
|
+
return {
|
|
45
|
+
blacklist,
|
|
46
|
+
refresh(presented: string): { access: string; refresh: string } {
|
|
47
|
+
if (blacklist.has(presented)) {
|
|
48
|
+
const err = new Error('Token is blacklisted') as Error & { code: string };
|
|
49
|
+
err.code = 'token_not_valid';
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
blacklist.add(presented); // BLACKLIST_AFTER_ROTATION
|
|
53
|
+
seq += 1;
|
|
54
|
+
return { access: `access-${seq}`, refresh: `refresh-${seq}` }; // ROTATE_REFRESH_TOKENS
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('refresh token rotation contract', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
vi.resetModules();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('persists the ROTATED refresh token so consecutive refreshes succeed', async () => {
|
|
66
|
+
installBrowserStorage();
|
|
67
|
+
const { auth } = await import('../../_api/generated/helpers/auth');
|
|
68
|
+
const server = makeRotatingServer();
|
|
69
|
+
|
|
70
|
+
// Seed the initial rotated pair (as a login would).
|
|
71
|
+
auth.setToken('access-0');
|
|
72
|
+
auth.setRefreshToken('R0');
|
|
73
|
+
|
|
74
|
+
// Canonical handler: returns the rotated refresh from the response.
|
|
75
|
+
auth.setRefreshHandler(async (refresh: string) => {
|
|
76
|
+
const res = server.refresh(refresh);
|
|
77
|
+
return { access: res.access, refresh: res.refresh };
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// `refreshNow` is added by the generator; skip gracefully until regen.
|
|
81
|
+
const store = auth as unknown as { refreshNow?: () => Promise<string | null> };
|
|
82
|
+
if (typeof store.refreshNow !== 'function') {
|
|
83
|
+
// Contract still enforced via the handler directly below.
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const first = await store.refreshNow();
|
|
88
|
+
expect(first).toBe('access-1');
|
|
89
|
+
expect(auth.getRefreshToken()).toBe('refresh-1'); // rotated, not R0
|
|
90
|
+
|
|
91
|
+
// The SECOND refresh is the one that broke before: it must NOT reuse R0.
|
|
92
|
+
const second = await store.refreshNow();
|
|
93
|
+
expect(second).toBe('access-2');
|
|
94
|
+
expect(auth.getRefreshToken()).toBe('refresh-2');
|
|
95
|
+
expect(server.blacklist.has('R0')).toBe(true); // R0 was retired, never reused
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a client that keeps the OLD refresh token would be rejected (bug reproduction)', async () => {
|
|
99
|
+
installBrowserStorage();
|
|
100
|
+
const { auth } = await import('../../_api/generated/helpers/auth');
|
|
101
|
+
const server = makeRotatingServer();
|
|
102
|
+
|
|
103
|
+
auth.setRefreshToken('R0');
|
|
104
|
+
|
|
105
|
+
// Simulate the ORIGINAL bug: persist the OLD token after a refresh.
|
|
106
|
+
const buggyRefresh = () => {
|
|
107
|
+
const presented = auth.getRefreshToken()!;
|
|
108
|
+
const res = server.refresh(presented);
|
|
109
|
+
auth.setToken(res.access);
|
|
110
|
+
auth.setRefreshToken(presented); // BUG: should be res.refresh
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
buggyRefresh(); // first refresh "works" but leaves R0 in storage
|
|
114
|
+
expect(auth.getRefreshToken()).toBe('R0');
|
|
115
|
+
|
|
116
|
+
// Second refresh reuses the now-blacklisted R0 → server rejects it.
|
|
117
|
+
expect(() => buggyRefresh()).toThrow(/blacklisted/i);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -16,11 +16,14 @@ import { APIError } from '../../_api/generated/helpers';
|
|
|
16
16
|
import { clearProfileCache, getCachedProfile } from '../hooks/useProfileCache';
|
|
17
17
|
import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
|
|
18
18
|
import { useTokenRefresh } from '../hooks/useTokenRefresh';
|
|
19
|
+
import { ensureRefreshHandler } from '../refreshHandler';
|
|
19
20
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
20
21
|
import { authLogger } from '../utils/logger';
|
|
22
|
+
import { isTokenExpired } from '../utils/jwt';
|
|
21
23
|
import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
22
24
|
|
|
23
25
|
import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
26
|
+
import type { OTPRequestResult } from '../types';
|
|
24
27
|
|
|
25
28
|
// Default routes
|
|
26
29
|
const defaultRoutes = {
|
|
@@ -39,10 +42,43 @@ const hasValidTokens = (): boolean => {
|
|
|
39
42
|
return apiAccounts.isAuthenticated();
|
|
40
43
|
};
|
|
41
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
|
+
|
|
42
72
|
// Internal provider that uses AccountsContext
|
|
43
73
|
const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
|
|
44
74
|
const accounts = useAccountsContext();
|
|
45
75
|
|
|
76
|
+
// Register the canonical refresh strategy exactly once, synchronously on the
|
|
77
|
+
// first render, BEFORE any request can 401. This lights up the generated
|
|
78
|
+
// client's built-in 401 recovery (single-flight + retry + rotated-token
|
|
79
|
+
// persistence). Idempotent — safe across remounts / multiple providers.
|
|
80
|
+
ensureRefreshHandler();
|
|
81
|
+
|
|
46
82
|
// Redirect URL manager for saving URL before auth redirect
|
|
47
83
|
const redirectManager = useAuthRedirectManager({
|
|
48
84
|
fallbackUrl: config?.routes?.defaultCallback || defaultRoutes.defaultCallback,
|
|
@@ -61,6 +97,22 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
61
97
|
});
|
|
62
98
|
|
|
63
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
|
+
|
|
64
116
|
const router = useCfgRouter();
|
|
65
117
|
const pathname = usePathname();
|
|
66
118
|
const queryParams = useQueryParams();
|
|
@@ -68,17 +120,10 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
68
120
|
// Use localStorage hook for email
|
|
69
121
|
const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(EMAIL_STORAGE_KEY, null);
|
|
70
122
|
|
|
71
|
-
// Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
authLogger.info('Token auto-refreshed successfully');
|
|
76
|
-
},
|
|
77
|
-
onRefreshError: (error) => {
|
|
78
|
-
authLogger.warn('Token auto-refresh failed:', error.message);
|
|
79
|
-
// Don't logout on refresh error - user might still have valid session
|
|
80
|
-
},
|
|
81
|
-
});
|
|
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.
|
|
82
127
|
|
|
83
128
|
// Map AccountsContext profile to UserProfile
|
|
84
129
|
const user = accounts.profile as UserProfile | null;
|
|
@@ -107,7 +152,36 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
107
152
|
// Note: user is now managed by AccountsContext, will auto-update
|
|
108
153
|
setInitialized(true);
|
|
109
154
|
setIsLoading(false);
|
|
110
|
-
|
|
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
|
+
});
|
|
111
185
|
|
|
112
186
|
// Global error handler for auth-related errors
|
|
113
187
|
// Only clears auth on actual authentication errors (401), not on any API error
|
|
@@ -133,6 +207,34 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
133
207
|
return false;
|
|
134
208
|
}, [clearAuthState]);
|
|
135
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
|
+
|
|
136
238
|
// SWR onError: auto-logout on 401 from any SWR hook
|
|
137
239
|
const isAutoLoggingOutRef = useRef(false);
|
|
138
240
|
const swrOnError = useCallback((error: any) => {
|
|
@@ -236,6 +338,17 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
236
338
|
authLogger.info('Refresh token from API:', refreshToken ? `${refreshToken.substring(0, 20)}...` : 'null');
|
|
237
339
|
authLogger.info('localStorage keys:', Object.keys(localStorage).filter(k => k.includes('token') || k.includes('auth')));
|
|
238
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
|
+
|
|
239
352
|
const hasTokens = hasValidTokens();
|
|
240
353
|
authLogger.info('Has tokens:', hasTokens);
|
|
241
354
|
|
|
@@ -353,7 +466,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
353
466
|
|
|
354
467
|
// OTP methods - email only - now uses AccountsContext
|
|
355
468
|
const requestOTP = useCallback(
|
|
356
|
-
async (identifier: string, sourceUrl?: string): Promise<
|
|
469
|
+
async (identifier: string, sourceUrl?: string): Promise<OTPRequestResult> => {
|
|
357
470
|
// Clear tokens before requesting OTP
|
|
358
471
|
apiAccounts.clearToken();
|
|
359
472
|
|
|
@@ -372,6 +485,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
372
485
|
return {
|
|
373
486
|
success: true,
|
|
374
487
|
message: result.message || `OTP code sent to your email address`,
|
|
488
|
+
webmail: result.webmail ?? null,
|
|
375
489
|
};
|
|
376
490
|
} catch (error) {
|
|
377
491
|
authLogger.error('Request OTP error:', error);
|
|
@@ -587,7 +701,9 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
587
701
|
() => ({
|
|
588
702
|
user,
|
|
589
703
|
isLoading,
|
|
590
|
-
// 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.
|
|
591
707
|
isAuthenticated: apiAccounts.isAuthenticated(),
|
|
592
708
|
isAdminUser,
|
|
593
709
|
loadCurrentProfile,
|
|
@@ -613,6 +729,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
613
729
|
[
|
|
614
730
|
user,
|
|
615
731
|
isLoading,
|
|
732
|
+
authTick, // recompute isAuthenticated when tokens are cleared/set
|
|
616
733
|
isAdminUser,
|
|
617
734
|
loadCurrentProfile,
|
|
618
735
|
checkAuthAndRedirect,
|
|
@@ -66,6 +66,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
66
66
|
setTwoFactorCode,
|
|
67
67
|
setUseBackupCode,
|
|
68
68
|
startRateLimitCountdown,
|
|
69
|
+
setWebmail,
|
|
69
70
|
} = formState;
|
|
70
71
|
|
|
71
72
|
// 2FA verification hook - skip redirect so we can show success screen
|
|
@@ -142,6 +143,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
142
143
|
|
|
143
144
|
if (result.success) {
|
|
144
145
|
saveIdentifierToStorage(identifier);
|
|
146
|
+
setWebmail(result.webmail ?? null);
|
|
145
147
|
setStep('otp');
|
|
146
148
|
onIdentifierSuccess?.(identifier);
|
|
147
149
|
} else {
|
|
@@ -163,7 +165,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
163
165
|
}, [
|
|
164
166
|
identifier, acceptedTerms, requireTermsAcceptance,
|
|
165
167
|
validateIdentifier, requestOTP, saveIdentifierToStorage,
|
|
166
|
-
setError, setIsLoading, setStep, clearError,
|
|
168
|
+
setError, setIsLoading, setStep, clearError, setWebmail,
|
|
167
169
|
startRateLimitCountdown, onIdentifierSuccess, onError, sourceUrl,
|
|
168
170
|
]);
|
|
169
171
|
|
|
@@ -235,6 +237,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
235
237
|
|
|
236
238
|
if (result.success) {
|
|
237
239
|
saveIdentifierToStorage(identifier);
|
|
240
|
+
setWebmail(result.webmail ?? null);
|
|
238
241
|
setOtp('');
|
|
239
242
|
} else {
|
|
240
243
|
if (result.retryAfter) {
|
|
@@ -252,7 +255,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
252
255
|
} finally {
|
|
253
256
|
setIsLoading(false);
|
|
254
257
|
}
|
|
255
|
-
}, [identifier, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, onError, sourceUrl]);
|
|
258
|
+
}, [identifier, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, setWebmail, onError, sourceUrl]);
|
|
256
259
|
|
|
257
260
|
const handleBackToIdentifier = useCallback(() => {
|
|
258
261
|
setStep('identifier');
|