@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.449",
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.449",
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
- useTokenRefresh({
81
- enabled: true,
82
- onRefresh: (newToken) => {
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;