@djangocfg/api 2.1.449 → 2.1.452

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.
@@ -0,0 +1,167 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * useTokenRefresh DOM tests.
5
+ *
6
+ * The proactive refresher decides WHEN to refresh (expiry timer, window focus,
7
+ * network reconnect) and delegates HOW to triggerRefresh. It is central to the
8
+ * stuck-auth bug: a proactive refresh that fails on a dead/blacklisted token
9
+ * must surface onRefreshError so AuthContext can collapse the session to the
10
+ * login form. These tests mock the api store + triggerRefresh and drive timers
11
+ * and window events.
12
+ */
13
+
14
+ import { renderHook, act } from '@testing-library/react';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+
17
+ // Mocks must be declared before importing the hook under test.
18
+ const mockGetToken = vi.fn<() => string | null>();
19
+ const mockGetRefreshToken = vi.fn<() => string | null>();
20
+ const mockTriggerRefresh = vi.fn<() => Promise<string | null>>();
21
+
22
+ vi.mock('../../', () => ({
23
+ api: {
24
+ getToken: () => mockGetToken(),
25
+ getRefreshToken: () => mockGetRefreshToken(),
26
+ },
27
+ }));
28
+ vi.mock('../refreshHandler', () => ({
29
+ triggerRefresh: () => mockTriggerRefresh(),
30
+ }));
31
+
32
+ import { useTokenRefresh } from '../hooks/useTokenRefresh';
33
+
34
+ const NOW = 1_800_000_000_000;
35
+ const expSeconds = (ms: number) => Math.floor(ms / 1000);
36
+ function b64url(o: unknown) {
37
+ return Buffer.from(JSON.stringify(o), 'utf8').toString('base64')
38
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
39
+ }
40
+ function jwt(exp: number) {
41
+ return `${b64url({ alg: 'HS256' })}.${b64url({ exp })}.sig`;
42
+ }
43
+ const liveToken = () => jwt(expSeconds(NOW + 60 * 60_000)); // +1h — not expiring
44
+ const expiringToken = () => jwt(expSeconds(NOW + 60_000)); // +1m — within 10m threshold
45
+
46
+ describe('useTokenRefresh', () => {
47
+ beforeEach(() => {
48
+ vi.useFakeTimers();
49
+ vi.setSystemTime(NOW);
50
+ mockGetToken.mockReset();
51
+ mockGetRefreshToken.mockReset();
52
+ mockTriggerRefresh.mockReset();
53
+ mockGetRefreshToken.mockReturnValue('R-valid');
54
+ mockTriggerRefresh.mockResolvedValue('access-new');
55
+ });
56
+ afterEach(() => {
57
+ vi.useRealTimers();
58
+ vi.restoreAllMocks();
59
+ });
60
+
61
+ it('refreshes on mount when the token is expiring soon', async () => {
62
+ mockGetToken.mockReturnValue(expiringToken());
63
+ renderHook(() => useTokenRefresh({ enabled: true }));
64
+ // initial checkAndRefresh runs in an effect
65
+ await act(async () => { await Promise.resolve(); });
66
+ expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
67
+ });
68
+
69
+ it('does NOT refresh on mount when the token is fresh', async () => {
70
+ mockGetToken.mockReturnValue(liveToken());
71
+ renderHook(() => useTokenRefresh({ enabled: true }));
72
+ await act(async () => { await Promise.resolve(); });
73
+ expect(mockTriggerRefresh).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('does nothing when there is no access token', async () => {
77
+ mockGetToken.mockReturnValue(null);
78
+ renderHook(() => useTokenRefresh({ enabled: true }));
79
+ await act(async () => { await Promise.resolve(); });
80
+ expect(mockTriggerRefresh).not.toHaveBeenCalled();
81
+ });
82
+
83
+ it('is inert when disabled', async () => {
84
+ mockGetToken.mockReturnValue(expiringToken());
85
+ renderHook(() => useTokenRefresh({ enabled: false }));
86
+ await act(async () => { await Promise.resolve(); });
87
+ expect(mockTriggerRefresh).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('fires onRefresh with the new token on success', async () => {
91
+ mockGetToken.mockReturnValue(expiringToken());
92
+ const onRefresh = vi.fn();
93
+ const { result } = renderHook(() => useTokenRefresh({ enabled: true, onRefresh }));
94
+ await act(async () => { await result.current.refreshToken(); });
95
+ expect(onRefresh).toHaveBeenCalledWith('access-new');
96
+ });
97
+
98
+ it('fires onRefreshError when the refresh fails (dead/blacklisted token)', async () => {
99
+ // Fresh token so the mount-effect check does NOT auto-refresh; we drive the
100
+ // failing refresh explicitly to isolate the error path.
101
+ mockGetToken.mockReturnValue(liveToken());
102
+ mockTriggerRefresh.mockResolvedValue(null); // refresh failed
103
+ const onRefreshError = vi.fn();
104
+ const { result } = renderHook(() => useTokenRefresh({ enabled: true, onRefreshError }));
105
+ await act(async () => { await Promise.resolve(); }); // let mount effect settle (no-op)
106
+ expect(mockTriggerRefresh).not.toHaveBeenCalled();
107
+
108
+ await act(async () => {
109
+ const ok = await result.current.refreshToken();
110
+ expect(ok).toBe(false);
111
+ });
112
+ expect(onRefreshError).toHaveBeenCalledTimes(1);
113
+ expect(onRefreshError.mock.calls[0][0]).toBeInstanceOf(Error);
114
+ });
115
+
116
+ it('refreshToken returns false and skips trigger when no refresh token exists', async () => {
117
+ mockGetToken.mockReturnValue(expiringToken());
118
+ mockGetRefreshToken.mockReturnValue(null);
119
+ const { result } = renderHook(() => useTokenRefresh({ enabled: true }));
120
+ await act(async () => {
121
+ const ok = await result.current.refreshToken();
122
+ expect(ok).toBe(false);
123
+ });
124
+ expect(mockTriggerRefresh).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('refreshes on window focus when the token is expiring', async () => {
128
+ mockGetToken.mockReturnValue(expiringToken());
129
+ renderHook(() => useTokenRefresh({ enabled: true }));
130
+ await act(async () => { await Promise.resolve(); }); // consume mount check
131
+ mockTriggerRefresh.mockClear();
132
+
133
+ await act(async () => {
134
+ window.dispatchEvent(new Event('focus'));
135
+ await Promise.resolve();
136
+ });
137
+ expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it('refreshes on network reconnect (online event)', async () => {
141
+ mockGetToken.mockReturnValue(expiringToken());
142
+ renderHook(() => useTokenRefresh({ enabled: true }));
143
+ await act(async () => { await Promise.resolve(); });
144
+ mockTriggerRefresh.mockClear();
145
+
146
+ await act(async () => {
147
+ window.dispatchEvent(new Event('online'));
148
+ await Promise.resolve();
149
+ });
150
+ expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
151
+ });
152
+
153
+ it('removes its event listeners on unmount', async () => {
154
+ mockGetToken.mockReturnValue(expiringToken());
155
+ const { unmount } = renderHook(() => useTokenRefresh({ enabled: true }));
156
+ await act(async () => { await Promise.resolve(); });
157
+ unmount();
158
+ mockTriggerRefresh.mockClear();
159
+
160
+ await act(async () => {
161
+ window.dispatchEvent(new Event('focus'));
162
+ window.dispatchEvent(new Event('online'));
163
+ await Promise.resolve();
164
+ });
165
+ expect(mockTriggerRefresh).not.toHaveBeenCalled();
166
+ });
167
+ });
@@ -1,37 +1,41 @@
1
+ // @vitest-environment jsdom
1
2
  /**
2
3
  * Tests for useTwoFactorStatus hook
3
4
  *
4
5
  * Tests the hook for checking and managing 2FA status in ProfileLayout.
6
+ * (Migrated from Jest to Vitest: jest.* → vi.*, added jsdom env + vi import.)
5
7
  */
6
8
 
7
- import { act, renderHook, waitFor } from '@testing-library/react';
9
+ import { act, renderHook } from '@testing-library/react';
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
11
 
9
12
  import { useTwoFactorStatus } from '../hooks/useTwoFactorStatus';
10
13
 
11
- // Mock apiTotp
12
- const mockList = jest.fn();
13
- const mockDisableCreate = jest.fn();
14
-
15
- jest.mock('../../clients', () => ({
16
- apiTotp: {
17
- totp_management: {
18
- list: () => mockList(),
19
- disableCreate: (data: any) => mockDisableCreate(data),
20
- },
14
+ // The hook calls the generated SDK (CfgTotp.cfgTotpDevicesRetrieve /
15
+ // cfgTotpDisableCreate), NOT the old apiTotp.totp_management surface — the hook
16
+ // was refactored and this suite was stale. Mock the SDK the hook actually uses.
17
+ // Retrieve resolves to `{ data }`; disable is awaited for its side effect only.
18
+ const mockDevicesRetrieve = vi.fn();
19
+ const mockDisableCreate = vi.fn();
20
+
21
+ vi.mock('../../_api/generated/sdk.gen', () => ({
22
+ CfgTotp: {
23
+ cfgTotpDevicesRetrieve: () => mockDevicesRetrieve(),
24
+ cfgTotpDisableCreate: (args: any) => mockDisableCreate(args),
21
25
  },
22
26
  }));
23
27
 
24
- jest.mock('../utils/logger', () => ({
28
+ vi.mock('../utils/logger', () => ({
25
29
  authLogger: {
26
- info: jest.fn(),
27
- warn: jest.fn(),
28
- error: jest.fn(),
30
+ info: vi.fn(),
31
+ warn: vi.fn(),
32
+ error: vi.fn(),
29
33
  },
30
34
  }));
31
35
 
32
36
  describe('useTwoFactorStatus', () => {
33
37
  beforeEach(() => {
34
- jest.clearAllMocks();
38
+ vi.clearAllMocks();
35
39
  });
36
40
 
37
41
  describe('initial state', () => {
@@ -47,17 +51,19 @@ describe('useTwoFactorStatus', () => {
47
51
 
48
52
  describe('fetchStatus', () => {
49
53
  it('should fetch 2FA status successfully when enabled', async () => {
50
- mockList.mockResolvedValue({
51
- has_2fa_enabled: true,
52
- devices: [
53
- {
54
- id: '123',
55
- name: 'My Authenticator',
56
- created_at: '2024-01-01T00:00:00Z',
57
- last_used_at: '2024-01-02T00:00:00Z',
58
- is_primary: true,
59
- },
60
- ],
54
+ mockDevicesRetrieve.mockResolvedValue({
55
+ data: {
56
+ has_2fa_enabled: true,
57
+ devices: [
58
+ {
59
+ id: '123',
60
+ name: 'My Authenticator',
61
+ created_at: '2024-01-01T00:00:00Z',
62
+ last_used_at: '2024-01-02T00:00:00Z',
63
+ is_primary: true,
64
+ },
65
+ ],
66
+ },
61
67
  });
62
68
 
63
69
  const { result } = renderHook(() => useTwoFactorStatus());
@@ -79,9 +85,11 @@ describe('useTwoFactorStatus', () => {
79
85
  });
80
86
 
81
87
  it('should fetch 2FA status successfully when disabled', async () => {
82
- mockList.mockResolvedValue({
83
- has_2fa_enabled: false,
84
- devices: [],
88
+ mockDevicesRetrieve.mockResolvedValue({
89
+ data: {
90
+ has_2fa_enabled: false,
91
+ devices: [],
92
+ },
85
93
  });
86
94
 
87
95
  const { result } = renderHook(() => useTwoFactorStatus());
@@ -96,7 +104,7 @@ describe('useTwoFactorStatus', () => {
96
104
  });
97
105
 
98
106
  it('should handle fetch error', async () => {
99
- mockList.mockRejectedValue(new Error('Network error'));
107
+ mockDevicesRetrieve.mockRejectedValue(new Error('Network error'));
100
108
 
101
109
  const { result } = renderHook(() => useTwoFactorStatus());
102
110
 
@@ -110,7 +118,7 @@ describe('useTwoFactorStatus', () => {
110
118
 
111
119
  it('should set loading state during fetch', async () => {
112
120
  let resolvePromise: (value: any) => void;
113
- mockList.mockReturnValue(
121
+ mockDevicesRetrieve.mockReturnValue(
114
122
  new Promise((resolve) => {
115
123
  resolvePromise = resolve;
116
124
  })
@@ -129,7 +137,7 @@ describe('useTwoFactorStatus', () => {
129
137
 
130
138
  // Resolve
131
139
  await act(async () => {
132
- resolvePromise!({ has_2fa_enabled: true, devices: [] });
140
+ resolvePromise!({ data: { has_2fa_enabled: true, devices: [] } });
133
141
  await fetchPromise;
134
142
  });
135
143
 
@@ -146,7 +154,7 @@ describe('useTwoFactorStatus', () => {
146
154
 
147
155
  // Set initial state
148
156
  await act(async () => {
149
- mockList.mockResolvedValue({ has_2fa_enabled: true, devices: [] });
157
+ mockDevicesRetrieve.mockResolvedValue({ data: { has_2fa_enabled: true, devices: [] } });
150
158
  await result.current.fetchStatus();
151
159
  });
152
160
 
@@ -161,7 +169,8 @@ describe('useTwoFactorStatus', () => {
161
169
  expect(success!).toBe(true);
162
170
  expect(result.current.has2FAEnabled).toBe(false);
163
171
  expect(result.current.devices).toEqual([]);
164
- expect(mockDisableCreate).toHaveBeenCalledWith({ code: '123456' });
172
+ // The hook calls cfgTotpDisableCreate({ body: { code }, throwOnError: true }).
173
+ expect(mockDisableCreate).toHaveBeenCalledWith({ body: { code: '123456' }, throwOnError: true });
165
174
  });
166
175
 
167
176
  it('should reject invalid code format', async () => {
@@ -207,7 +216,7 @@ describe('useTwoFactorStatus', () => {
207
216
 
208
217
  describe('clearError', () => {
209
218
  it('should clear error', async () => {
210
- mockList.mockRejectedValue(new Error('Some error'));
219
+ mockDevicesRetrieve.mockRejectedValue(new Error('Some error'));
211
220
 
212
221
  const { result } = renderHook(() => useTwoFactorStatus());
213
222
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Identifier/email validation tests.
3
+ *
4
+ * Two copies of the email regex exist (utils/validation.validateEmail and
5
+ * hooks/useAuthValidation.validateIdentifier) and gate whether the login form
6
+ * lets a submit through. They must agree and must reject the usual malformed
7
+ * inputs (spaces, double-@, missing TLD) without being so strict they reject
8
+ * valid addresses (plus-tags, subdomains).
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { validateEmail } from '../utils/validation';
14
+ import { validateIdentifier } from '../hooks/useAuthValidation';
15
+
16
+ const VALID = [
17
+ 'a@b.co',
18
+ 'user@example.com',
19
+ 'user.name@example.com',
20
+ 'user+tag@example.com',
21
+ 'user@sub.example.co.uk',
22
+ 'USER@EXAMPLE.COM',
23
+ '123@numbers.io',
24
+ ];
25
+
26
+ const INVALID = [
27
+ '',
28
+ 'plainstring',
29
+ 'no-at-sign.com',
30
+ '@no-local.com',
31
+ 'no-domain@',
32
+ 'no-tld@example',
33
+ 'two@@example.com',
34
+ 'spaces in@example.com',
35
+ 'trailing@space.com ',
36
+ ' leading@space.com',
37
+ 'new\nline@example.com',
38
+ 'a@b@c.com',
39
+ ];
40
+
41
+ describe('validateEmail (utils/validation)', () => {
42
+ it.each(VALID)('accepts %s', (email) => {
43
+ expect(validateEmail(email)).toBe(true);
44
+ });
45
+ it.each(INVALID)('rejects %j', (email) => {
46
+ expect(validateEmail(email)).toBe(false);
47
+ });
48
+ });
49
+
50
+ describe('validateIdentifier (useAuthValidation) matches validateEmail', () => {
51
+ it.each([...VALID, ...INVALID])('agrees on %j', (input) => {
52
+ expect(validateIdentifier(input)).toBe(validateEmail(input));
53
+ });
54
+ });
@@ -19,6 +19,8 @@ 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, getTokenExpiry } from '../utils/jwt';
23
+ import { nextAuthEvaluationDelay } from '../utils/guard';
22
24
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
23
25
 
24
26
  import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
@@ -41,6 +43,58 @@ const hasValidTokens = (): boolean => {
41
43
  return apiAccounts.isAuthenticated();
42
44
  };
43
45
 
46
+ /**
47
+ * Decide, at bootstrap, whether the persisted tokens are a DEAD session that
48
+ * can never be recovered — so we should clear storage and show the login form
49
+ * instead of firing doomed API calls.
50
+ *
51
+ * Pure + injectable (tokens passed in) so it can be unit-tested without a store.
52
+ *
53
+ * A session is dead when the access token is missing/expired/malformed AND the
54
+ * refresh token cannot save it (missing or itself expired/malformed). If either
55
+ * the access token is still live, OR a usable refresh token exists, the session
56
+ * is NOT dead — normal init proceeds (and refresh will run if needed).
57
+ *
58
+ * This is exactly the state the user used to fix by hand ("cleared localStorage
59
+ * and then the form appeared"): stale/blacklisted-companion tokens left in
60
+ * storage with no path forward.
61
+ */
62
+ export const isSessionDeadOnBootstrap = (
63
+ accessToken: string | null,
64
+ refreshToken: string | null,
65
+ now = Date.now(),
66
+ ): boolean => {
67
+ const accessDead = isTokenExpired(accessToken, 0, now);
68
+ if (!accessDead) return false; // access still usable → alive
69
+ const refreshDead = isTokenExpired(refreshToken, 0, now);
70
+ return refreshDead; // access dead + refresh dead/absent → unrecoverable
71
+ };
72
+
73
+ /**
74
+ * Live, network-free authentication check — the single source of truth for
75
+ * `isAuthenticated`. Reads the persisted tokens and validates their `exp`
76
+ * LOCALLY, so the answer never depends on a server 401 arriving (which CSP, a
77
+ * hung request, or an offline network can silently swallow).
78
+ *
79
+ * A session is alive while it is not `isSessionDeadOnBootstrap` — i.e. the
80
+ * access token is still valid, OR a usable refresh token exists to renew it.
81
+ * We deliberately do NOT require a live access token: between access expiry and
82
+ * the silent refresh the session is still recoverable, and flipping to
83
+ * "unauthenticated" there would bounce the user to /auth on every protected
84
+ * page. Only access-dead AND refresh-dead means truly logged out.
85
+ *
86
+ * Note: `isAuthenticated()` in the generated store only checks token PRESENCE,
87
+ * not expiry — this wrapper adds the expiry gate the guard needs.
88
+ */
89
+ const isSessionAlive = (now = Date.now()): boolean => {
90
+ if (typeof window === 'undefined') return false;
91
+ return !isSessionDeadOnBootstrap(
92
+ apiAccounts.getToken(),
93
+ apiAccounts.getRefreshToken(),
94
+ now,
95
+ );
96
+ };
97
+
44
98
  // Internal provider that uses AccountsContext
45
99
  const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
46
100
  const accounts = useAccountsContext();
@@ -69,6 +123,43 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
69
123
  });
70
124
 
71
125
  const [initialized, setInitialized] = useState(false);
126
+
127
+ // Reactivity tick for `isAuthenticated`. Token state lives in the auth store
128
+ // (localStorage), NOT React state — so `apiAccounts.isAuthenticated()` is read
129
+ // once when the context value is memoised and would go stale after tokens are
130
+ // cleared (e.g. a blacklisted-refresh 401). Bumping this on every auth-state
131
+ // transition forces the memo to recompute, so the guard reliably flips to
132
+ // "unauthenticated" and the login form is shown instead of an endless
133
+ // "Authenticating…" spinner.
134
+ const [authTick, setAuthTick] = useState(0);
135
+ const bumpAuthTick = useCallback(() => setAuthTick((t) => t + 1), []);
136
+
137
+ // Time-driven re-evaluation of `isAuthenticated`. Because the flag is derived
138
+ // from token `exp` (not from a server event), a session can silently expire
139
+ // while the tab sits open — with nothing to trigger a recompute. So we arm a
140
+ // timer for the next moment the answer could change: whichever of the access
141
+ // / refresh tokens expires next. When it fires we bump the tick; the memo
142
+ // recomputes; the guard flips to unauthenticated and redirects — no 401 (and
143
+ // thus no CSP/offline dependency) required. Re-armed whenever tokens change
144
+ // (authTick) so a fresh refresh pushes the deadline out.
145
+ useEffect(() => {
146
+ if (typeof window === 'undefined') return;
147
+ const delay = nextAuthEvaluationDelay(
148
+ getTokenExpiry(apiAccounts.getToken()),
149
+ getTokenExpiry(apiAccounts.getRefreshToken()),
150
+ Date.now(),
151
+ );
152
+ if (delay === null) return; // nothing live to expire
153
+ // setTimeout caps at ~24.8 days; clamp so a far-future refresh doesn't overflow.
154
+ const timer = setTimeout(bumpAuthTick, Math.min(delay, 2_000_000_000));
155
+ return () => clearTimeout(timer);
156
+ }, [authTick, bumpAuthTick]);
157
+
158
+ // Latches once a dead session has been handled, so the redirect-to-login only
159
+ // fires once even if both the interceptor (auth.onUnauthorized) and the
160
+ // proactive-refresh error path trip for the same expired session.
161
+ const onUnauthorizedRef = useRef(false);
162
+
72
163
  const router = useCfgRouter();
73
164
  const pathname = usePathname();
74
165
  const queryParams = useQueryParams();
@@ -76,17 +167,10 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
76
167
  // Use localStorage hook for email
77
168
  const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(EMAIL_STORAGE_KEY, null);
78
169
 
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
- });
170
+ // Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect.
171
+ // NOTE: onRefreshError is wired below, after clearAuthState is defined
172
+ // (see handleProactiveRefreshError), so it can collapse a dead session to the
173
+ // login form. useTokenRefresh only runs when a refresh token exists.
90
174
 
91
175
  // Map AccountsContext profile to UserProfile
92
176
  const user = accounts.profile as UserProfile | null;
@@ -115,7 +199,36 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
115
199
  // Note: user is now managed by AccountsContext, will auto-update
116
200
  setInitialized(true);
117
201
  setIsLoading(false);
118
- }, []);
202
+ // Tokens just went away — recompute `isAuthenticated` so the guard shows
203
+ // the login form instead of hanging on the preloader.
204
+ bumpAuthTick();
205
+ }, [bumpAuthTick]);
206
+
207
+ // Proactive-refresh failure handler. useTokenRefresh (expiry timer / focus /
208
+ // reconnect) runs OUTSIDE the response interceptor, so its failures do NOT go
209
+ // through auth.onUnauthorized. A failed proactive refresh means the refresh
210
+ // token is dead (expired, or blacklisted after rotation) → the session is
211
+ // unrecoverable → clear it and show the login form. This is what was missing:
212
+ // the old handler just logged a warning and left stale tokens, so the app hung
213
+ // on "Authenticating…" forever.
214
+ const handleProactiveRefreshError = useCallback((error: Error) => {
215
+ authLogger.warn('Proactive token refresh failed — session is dead, redirecting to login:', error.message);
216
+ // Guard against the redirect firing twice if the interceptor path already tripped.
217
+ if (onUnauthorizedRef.current) return;
218
+ onUnauthorizedRef.current = true;
219
+ clearAuthState('proactiveRefresh:failed');
220
+ const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
221
+ router.hardReplace(authCallbackUrl);
222
+ }, [clearAuthState, router]);
223
+
224
+ // Automatic token refresh — refreshes before expiry, on focus, and on reconnect.
225
+ useTokenRefresh({
226
+ enabled: true,
227
+ onRefresh: () => {
228
+ authLogger.info('Token auto-refreshed successfully');
229
+ },
230
+ onRefreshError: handleProactiveRefreshError,
231
+ });
119
232
 
120
233
  // Global error handler for auth-related errors
121
234
  // Only clears auth on actual authentication errors (401), not on any API error
@@ -141,6 +254,34 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
141
254
  return false;
142
255
  }, [clearAuthState]);
143
256
 
257
+ // Unrecoverable-401 handler from the generated client. Fires ONLY when a 401
258
+ // could not be recovered by the refresh path — no refresh token, no handler,
259
+ // the refresh call failed (e.g. "Token is blacklisted" after rotation), or
260
+ // the retried request still 401'd. Transparently-recovered 401s never reach
261
+ // here. This is the single place that guarantees a dead session collapses to
262
+ // the login form: clear tokens + hard-redirect to /auth.
263
+ //
264
+ // Without this, a proactive refresh (useTokenRefresh) hitting a blacklisted
265
+ // token would just log a warning and leave stale tokens in storage, so the
266
+ // guard stayed on "Authenticating…" forever and never rendered the form.
267
+ useEffect(() => {
268
+ const handler = () => {
269
+ if (onUnauthorizedRef.current) return; // de-dupe concurrent 401s
270
+ onUnauthorizedRef.current = true;
271
+ authLogger.warn('Unrecoverable 401 (refresh failed) — clearing session and redirecting to login');
272
+ clearAuthState('onUnauthorized:401');
273
+ const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
274
+ router.hardReplace(authCallbackUrl);
275
+ };
276
+ apiAccounts.onUnauthorized(handler);
277
+ return () => {
278
+ // Only clear if we're still the registered handler (StrictMode double-mount
279
+ // can register a newer one before this cleanup runs).
280
+ apiAccounts.onUnauthorized(null);
281
+ };
282
+ // clearAuthState/router are stable (useCallback); configRef is a ref.
283
+ }, [clearAuthState, router]);
284
+
144
285
  // SWR onError: auto-logout on 401 from any SWR hook
145
286
  const isAutoLoggingOutRef = useRef(false);
146
287
  const swrOnError = useCallback((error: any) => {
@@ -244,6 +385,17 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
244
385
  authLogger.info('Refresh token from API:', refreshToken ? `${refreshToken.substring(0, 20)}...` : 'null');
245
386
  authLogger.info('localStorage keys:', Object.keys(localStorage).filter(k => k.includes('token') || k.includes('auth')));
246
387
 
388
+ // Fail-fast: if storage holds a DEAD session (access expired/malformed AND
389
+ // refresh unusable), clear it and show the login form immediately — never
390
+ // fire doomed profile/refresh calls that just 401 and hang the UI. This is
391
+ // the automated version of "clear localStorage, then the form appears".
392
+ // Skipped in iframe mode: the parent frame owns token delivery there.
393
+ if (!isInIframe && isSessionDeadOnBootstrap(token, refreshToken)) {
394
+ authLogger.warn('Bootstrap: dead session in storage (access + refresh both unusable) — clearing and showing login');
395
+ clearAuthState('initializeAuth:deadSession');
396
+ return;
397
+ }
398
+
247
399
  const hasTokens = hasValidTokens();
248
400
  authLogger.info('Has tokens:', hasTokens);
249
401
 
@@ -596,8 +748,13 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
596
748
  () => ({
597
749
  user,
598
750
  isLoading,
599
- // Consider authenticated if we have valid tokens, even without user profile
600
- isAuthenticated: apiAccounts.isAuthenticated(),
751
+ // Authenticated iff the session is locally alive (access valid OR a usable
752
+ // refresh token) — validated by `exp`, NOT by mere token presence. This is
753
+ // what makes the guard self-sufficient: an expired session reads as
754
+ // unauthenticated WITHOUT waiting for a server 401 (which CSP / a hung
755
+ // request / offline can swallow). `authTick` (in deps) forces recompute
756
+ // after tokens are cleared/set, since token state lives in storage.
757
+ isAuthenticated: isSessionAlive(),
601
758
  isAdminUser,
602
759
  loadCurrentProfile,
603
760
  checkAuthAndRedirect,
@@ -622,6 +779,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
622
779
  [
623
780
  user,
624
781
  isLoading,
782
+ authTick, // recompute isAuthenticated when tokens are cleared/set
625
783
  isAdminUser,
626
784
  loadCurrentProfile,
627
785
  checkAuthAndRedirect,
@@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react';
5
5
 
6
6
  import { AUTH_CONSTANTS } from '../constants';
7
7
  import { authLogger } from '../utils/logger';
8
+ import { isAllowedAuthPath } from '../utils/path';
8
9
  import { useCfgRouter } from './useCfgRouter';
9
10
  import { useQueryParams } from './useQueryParams';
10
11
 
@@ -16,21 +17,11 @@ export interface UseAutoAuthOptions {
16
17
  allowedPaths?: string[];
17
18
  }
18
19
 
19
- /**
20
- * Strip a leading 2-letter locale segment and any trailing slash so paths
21
- * compare consistently regardless of the next-intl locale prefix.
22
- * e.g. '/ru/auth/' -> '/auth', '/auth' -> '/auth', '/' -> '/'
23
- */
24
- const normalizePath = (path: string | null): string => {
25
- if (!path) return '';
26
- const withoutLocale = path.replace(/^\/[a-z]{2}(?=\/|$)/, '');
27
- const trimmed = withoutLocale.replace(/\/+$/, '');
28
- return trimmed || '/';
29
- };
30
-
31
20
  /**
32
21
  * Hook for automatic authentication from URL query parameters
33
- * Detects OTP from URL and triggers callback
22
+ * Detects OTP from URL and triggers callback.
23
+ * (Path normalization / allow-list matching live in ../utils/path so they can
24
+ * be unit-tested independently of the hook.)
34
25
  */
35
26
  export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
36
27
  const { onOTPDetected, cleanupUrl = true, allowedPaths = ['/auth'] } = options;
@@ -40,10 +31,7 @@ export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
40
31
 
41
32
  // Check if current path is in allowed paths, ignoring the locale prefix
42
33
  // (next-intl may keep it in the URL, e.g. /ru/auth) and trailing slashes.
43
- const normalizedPath = normalizePath(pathname);
44
- const isAllowedPath = allowedPaths.some(
45
- path => normalizedPath === path || normalizedPath.startsWith(path + '/')
46
- );
34
+ const isAllowedPath = isAllowedAuthPath(pathname, allowedPaths);
47
35
 
48
36
  const queryOtp = queryParams.get('otp') || '';
49
37
  const queryEmail = queryParams.get('email') || undefined;