@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.
- package/dist/auth.cjs +147 -25
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +107 -2
- package/dist/auth.d.ts +107 -2
- package/dist/auth.mjs +147 -25
- package/dist/auth.mjs.map +1 -1
- package/package.json +4 -2
- package/src/auth/__tests__/authRedirect.test.ts +101 -0
- package/src/auth/__tests__/base64.test.ts +53 -0
- package/src/auth/__tests__/constants.test.ts +30 -0
- package/src/auth/__tests__/formatAuthError.test.ts +48 -0
- package/src/auth/__tests__/guard.test.ts +144 -0
- package/src/auth/__tests__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
- package/src/auth/__tests__/path.test.ts +67 -0
- package/src/auth/__tests__/profileCache.test.ts +138 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +111 -0
- package/src/auth/__tests__/useAuthForm.2fa.test.ts +28 -18
- package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +167 -0
- package/src/auth/__tests__/useTwoFactorStatus.test.ts +45 -36
- package/src/auth/__tests__/validation.test.ts +54 -0
- package/src/auth/context/AuthContext.tsx +172 -14
- package/src/auth/hooks/useAutoAuth.ts +5 -17
- package/src/auth/hooks/useTokenRefresh.ts +3 -21
- package/src/auth/utils/guard.ts +79 -0
- package/src/auth/utils/index.ts +9 -0
- package/src/auth/utils/jwt.ts +66 -0
- package/src/auth/utils/path.ts +34 -0
|
@@ -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
|
|
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
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
+
vi.mock('../utils/logger', () => ({
|
|
25
29
|
authLogger: {
|
|
26
|
-
info:
|
|
27
|
-
warn:
|
|
28
|
-
error:
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
authLogger.info('Token auto-refreshed successfully');
|
|
84
|
-
},
|
|
85
|
-
onRefreshError: (error) => {
|
|
86
|
-
authLogger.warn('Token auto-refresh failed:', error.message);
|
|
87
|
-
// Don't logout on refresh error - user might still have valid session
|
|
88
|
-
},
|
|
89
|
-
});
|
|
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
|
-
//
|
|
600
|
-
|
|
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
|
|
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;
|