@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.
Files changed (55) hide show
  1. package/dist/auth-server.cjs +1 -2272
  2. package/dist/auth-server.cjs.map +1 -1
  3. package/dist/auth-server.d.cts +1 -35
  4. package/dist/auth-server.d.ts +1 -35
  5. package/dist/auth-server.mjs +1 -2272
  6. package/dist/auth-server.mjs.map +1 -1
  7. package/dist/auth.cjs +285 -146
  8. package/dist/auth.cjs.map +1 -1
  9. package/dist/auth.d.cts +137 -2
  10. package/dist/auth.d.ts +137 -2
  11. package/dist/auth.mjs +294 -155
  12. package/dist/auth.mjs.map +1 -1
  13. package/dist/clients.cjs +35 -0
  14. package/dist/clients.cjs.map +1 -1
  15. package/dist/clients.d.cts +147 -2
  16. package/dist/clients.d.ts +147 -2
  17. package/dist/clients.mjs +35 -0
  18. package/dist/clients.mjs.map +1 -1
  19. package/dist/hooks.cjs +11 -0
  20. package/dist/hooks.cjs.map +1 -1
  21. package/dist/hooks.mjs +11 -0
  22. package/dist/hooks.mjs.map +1 -1
  23. package/dist/index.cjs +35 -0
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +169 -6
  26. package/dist/index.d.ts +169 -6
  27. package/dist/index.mjs +35 -0
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +7 -4
  30. package/src/_api/generated/_cfg_accounts/api.ts +8 -0
  31. package/src/_api/generated/_cfg_accounts/openapi.json +84 -5
  32. package/src/_api/generated/_cfg_accounts/schemas/OAuthTokenResponse.ts +2 -2
  33. package/src/_api/generated/_cfg_accounts/schemas/OTPRequestResponse.ts +2 -0
  34. package/src/_api/generated/_cfg_accounts/schemas/WebmailLink.ts +15 -0
  35. package/src/_api/generated/_cfg_accounts/schemas/WebmailLinkProviderEnum.ts +9 -0
  36. package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
  37. package/src/_api/generated/_cfg_centrifugo/api.ts +8 -0
  38. package/src/_api/generated/_cfg_totp/api.ts +8 -0
  39. package/src/_api/generated/helpers/auth.ts +12 -0
  40. package/src/_api/generated/openapi.json +84 -5
  41. package/src/_api/generated/types.gen.ts +131 -2
  42. package/src/auth/__tests__/jwt.test.ts +119 -0
  43. package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
  44. package/src/auth/__tests__/refreshRotation.test.ts +119 -0
  45. package/src/auth/__tests__/sessionBootstrap.test.ts +76 -0
  46. package/src/auth/context/AuthContext.tsx +131 -14
  47. package/src/auth/hooks/useAuthForm.ts +5 -2
  48. package/src/auth/hooks/useAuthFormState.ts +4 -1
  49. package/src/auth/hooks/useTokenRefresh.ts +28 -62
  50. package/src/auth/middlewares/index.ts +7 -7
  51. package/src/auth/refreshHandler.ts +79 -0
  52. package/src/auth/types/form.ts +10 -0
  53. package/src/auth/types/index.ts +1 -0
  54. package/src/auth/utils/jwt.ts +66 -0
  55. 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
- useTokenRefresh({
73
- enabled: true,
74
- onRefresh: (newToken) => {
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<{ success: boolean; message: string; statusCode?: number; retryAfter?: number }> => {
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');