@djangocfg/api 2.1.450 → 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 +70 -8
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +105 -2
- package/dist/auth.d.ts +105 -2
- package/dist/auth.mjs +70 -8
- 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__/path.test.ts +67 -0
- package/src/auth/__tests__/profileCache.test.ts +138 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +35 -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 +55 -5
- package/src/auth/hooks/useAutoAuth.ts +5 -17
- package/src/auth/utils/guard.ts +79 -0
- package/src/auth/utils/index.ts +9 -0
- package/src/auth/utils/path.ts +34 -0
|
@@ -7,38 +7,48 @@
|
|
|
7
7
|
* branch relies on.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// @vitest-environment jsdom
|
|
10
11
|
import { act, renderHook } from '@testing-library/react';
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
13
|
|
|
12
14
|
import { useAuthForm } from '../hooks/useAuthForm';
|
|
13
15
|
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
+
// useAuthForm pulls in useAutoAuth → useCfgRouter → next/navigation, which
|
|
17
|
+
// throws outside an app-router provider. Stub the navigation surface it uses.
|
|
18
|
+
vi.mock('next/navigation', () => ({
|
|
19
|
+
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), back: vi.fn() }),
|
|
20
|
+
usePathname: () => '/auth',
|
|
21
|
+
useSearchParams: () => new URLSearchParams(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock dependencies (migrated from Jest → Vitest)
|
|
25
|
+
vi.mock('../context', () => ({
|
|
16
26
|
useAuth: () => ({
|
|
17
|
-
requestOTP:
|
|
18
|
-
verifyOTP:
|
|
19
|
-
getSavedEmail:
|
|
20
|
-
saveEmail:
|
|
21
|
-
clearSavedEmail:
|
|
22
|
-
getSavedPhone:
|
|
23
|
-
savePhone:
|
|
24
|
-
clearSavedPhone:
|
|
27
|
+
requestOTP: vi.fn().mockResolvedValue({ success: true }),
|
|
28
|
+
verifyOTP: vi.fn(),
|
|
29
|
+
getSavedEmail: vi.fn().mockReturnValue(null),
|
|
30
|
+
saveEmail: vi.fn(),
|
|
31
|
+
clearSavedEmail: vi.fn(),
|
|
32
|
+
getSavedPhone: vi.fn().mockReturnValue(null),
|
|
33
|
+
savePhone: vi.fn(),
|
|
34
|
+
clearSavedPhone: vi.fn(),
|
|
25
35
|
}),
|
|
26
36
|
}));
|
|
27
37
|
|
|
28
|
-
|
|
38
|
+
vi.mock('../hooks/useTwoFactor', () => ({
|
|
29
39
|
useTwoFactor: () => ({
|
|
30
40
|
isLoading: false,
|
|
31
41
|
warning: null,
|
|
32
|
-
verifyTOTP:
|
|
33
|
-
verifyBackupCode:
|
|
42
|
+
verifyTOTP: vi.fn(),
|
|
43
|
+
verifyBackupCode: vi.fn(),
|
|
34
44
|
}),
|
|
35
45
|
}));
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
vi.mock('../utils/logger', () => ({
|
|
38
48
|
authLogger: {
|
|
39
|
-
info:
|
|
40
|
-
warn:
|
|
41
|
-
error:
|
|
49
|
+
info: vi.fn(),
|
|
50
|
+
warn: vi.fn(),
|
|
51
|
+
error: vi.fn(),
|
|
42
52
|
},
|
|
43
53
|
}));
|
|
44
54
|
|
|
@@ -49,7 +59,7 @@ describe('useAuthForm - 2FA', () => {
|
|
|
49
59
|
};
|
|
50
60
|
|
|
51
61
|
beforeEach(() => {
|
|
52
|
-
|
|
62
|
+
vi.clearAllMocks();
|
|
53
63
|
});
|
|
54
64
|
|
|
55
65
|
describe('step transitions', () => {
|
|
@@ -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,7 +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 } from '../utils/jwt';
|
|
22
|
+
import { isTokenExpired, getTokenExpiry } from '../utils/jwt';
|
|
23
|
+
import { nextAuthEvaluationDelay } from '../utils/guard';
|
|
23
24
|
import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
24
25
|
|
|
25
26
|
import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
@@ -69,6 +70,31 @@ export const isSessionDeadOnBootstrap = (
|
|
|
69
70
|
return refreshDead; // access dead + refresh dead/absent → unrecoverable
|
|
70
71
|
};
|
|
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
|
+
|
|
72
98
|
// Internal provider that uses AccountsContext
|
|
73
99
|
const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
|
|
74
100
|
const accounts = useAccountsContext();
|
|
@@ -108,6 +134,27 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
108
134
|
const [authTick, setAuthTick] = useState(0);
|
|
109
135
|
const bumpAuthTick = useCallback(() => setAuthTick((t) => t + 1), []);
|
|
110
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
|
+
|
|
111
158
|
// Latches once a dead session has been handled, so the redirect-to-login only
|
|
112
159
|
// fires once even if both the interceptor (auth.onUnauthorized) and the
|
|
113
160
|
// proactive-refresh error path trip for the same expired session.
|
|
@@ -701,10 +748,13 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
701
748
|
() => ({
|
|
702
749
|
user,
|
|
703
750
|
isLoading,
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
|
|
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(),
|
|
708
758
|
isAdminUser,
|
|
709
759
|
loadCurrentProfile,
|
|
710
760
|
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;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure auth-guard state resolution.
|
|
3
|
+
*
|
|
4
|
+
* This is the decision that produced the "stuck on Authenticating…" bug: given
|
|
5
|
+
* the auth state, should the UI show a preloader, redirect to login, or render
|
|
6
|
+
* the protected content? Extracted from useAuthGuard (which lives in a package
|
|
7
|
+
* with no test infra) so the branching can be unit-tested in isolation — the
|
|
8
|
+
* hook just wires React state (mounted / isRedirecting) into these functions.
|
|
9
|
+
*
|
|
10
|
+
* Invariant that fixes the bug: once auth is resolved (`authLoading === false`)
|
|
11
|
+
* and the user is unauthenticated, `isLoading` must become false so the guard
|
|
12
|
+
* redirects to the form instead of spinning forever.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface GuardInput {
|
|
16
|
+
/** First client render matches SSR (preloader) until mounted, to avoid hydration mismatch. */
|
|
17
|
+
mounted: boolean;
|
|
18
|
+
/** Whether this route requires authentication. */
|
|
19
|
+
requireAuth: boolean;
|
|
20
|
+
/** AuthContext still resolving initial state. */
|
|
21
|
+
authLoading: boolean;
|
|
22
|
+
/** A redirect to the auth page is in flight. */
|
|
23
|
+
isRedirecting: boolean;
|
|
24
|
+
/** User currently has a (valid-looking) session. */
|
|
25
|
+
isAuthenticated: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True while the guard should render a preloader rather than the protected
|
|
30
|
+
* content. Mirrors useAuthGuard's formula exactly.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveGuardIsLoading(s: GuardInput): boolean {
|
|
33
|
+
if (!s.mounted) return true; // pre-hydration: always preloader
|
|
34
|
+
if (!s.requireAuth) return false; // public route: never blocks
|
|
35
|
+
return s.authLoading || s.isRedirecting || !s.isAuthenticated;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether the guard should trigger a redirect to the auth page. Only once
|
|
40
|
+
* mounted, on a protected route, after auth resolved, when unauthenticated and
|
|
41
|
+
* not already redirecting. This is the branch that must fire for a dead session.
|
|
42
|
+
*/
|
|
43
|
+
export function shouldRedirectToAuth(s: GuardInput): boolean {
|
|
44
|
+
return (
|
|
45
|
+
s.mounted &&
|
|
46
|
+
s.requireAuth &&
|
|
47
|
+
!s.authLoading &&
|
|
48
|
+
!s.isAuthenticated &&
|
|
49
|
+
!s.isRedirecting
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The effective authenticated flag the guard exposes to consumers. */
|
|
54
|
+
export function resolveGuardIsAuthenticated(s: Pick<GuardInput, 'requireAuth' | 'isAuthenticated'>): boolean {
|
|
55
|
+
return !s.requireAuth || s.isAuthenticated;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Delay (ms from `now`) until `isAuthenticated` should next be re-evaluated,
|
|
60
|
+
* given the access/refresh expiry timestamps (ms epoch, or null if absent).
|
|
61
|
+
*
|
|
62
|
+
* `isAuthenticated` is derived from token `exp`, so it can change with no event
|
|
63
|
+
* to trigger a recompute — a tab left open silently crosses an expiry boundary.
|
|
64
|
+
* We schedule a wake-up at the SOONEST future expiry (the next moment the answer
|
|
65
|
+
* could flip), plus a 1s cushion so we re-check strictly after the inclusive
|
|
66
|
+
* boundary. Returns null when nothing live remains to expire (no timer needed).
|
|
67
|
+
* Callers should clamp the result to the platform setTimeout ceiling.
|
|
68
|
+
*/
|
|
69
|
+
export function nextAuthEvaluationDelay(
|
|
70
|
+
accessExp: number | null,
|
|
71
|
+
refreshExp: number | null,
|
|
72
|
+
now: number,
|
|
73
|
+
): number | null {
|
|
74
|
+
const future = [accessExp, refreshExp].filter(
|
|
75
|
+
(d): d is number => d !== null && d > now,
|
|
76
|
+
);
|
|
77
|
+
if (future.length === 0) return null;
|
|
78
|
+
return Math.max(0, Math.min(...future) - now) + 1000;
|
|
79
|
+
}
|