@startsimpli/auth 0.4.2 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/auth-functions.test.ts +271 -0
- package/src/__tests__/token-storage.test.ts +52 -11
- package/src/__tests__/validation.test.ts +41 -19
- package/src/client/auth-context.tsx +9 -4
- package/src/client/functions.ts +119 -32
- package/src/client/index.ts +1 -1
- package/src/client/use-auth.ts +46 -1
- package/src/index.ts +4 -1
- package/src/validation/index.ts +29 -11
package/package.json
CHANGED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auth function fixes:
|
|
3
|
+
* - CSRF token included in signin/register
|
|
4
|
+
* - authFetch triggers session expiration on unrecoverable 401
|
|
5
|
+
* - signOut clears all cookies
|
|
6
|
+
* - refreshAccessToken clears session on failure
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
9
|
+
|
|
10
|
+
const mockFetch = vi.fn();
|
|
11
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
12
|
+
|
|
13
|
+
// Mock sessionStorage
|
|
14
|
+
const mockSessionStorage = (() => {
|
|
15
|
+
let store: Record<string, string> = {};
|
|
16
|
+
return {
|
|
17
|
+
getItem: (k: string) => store[k] ?? null,
|
|
18
|
+
setItem: (k: string, v: string) => { store[k] = v; },
|
|
19
|
+
removeItem: (k: string) => { delete store[k]; },
|
|
20
|
+
clear: () => { store = {}; },
|
|
21
|
+
};
|
|
22
|
+
})();
|
|
23
|
+
vi.stubGlobal('sessionStorage', mockSessionStorage);
|
|
24
|
+
|
|
25
|
+
// Mock localStorage
|
|
26
|
+
const mockLocalStorage = (() => {
|
|
27
|
+
let store: Record<string, string> = {};
|
|
28
|
+
return {
|
|
29
|
+
getItem: (k: string) => store[k] ?? null,
|
|
30
|
+
setItem: (k: string, v: string) => { store[k] = v; },
|
|
31
|
+
removeItem: (k: string) => { delete store[k]; },
|
|
32
|
+
clear: () => { store = {}; },
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
vi.stubGlobal('localStorage', mockLocalStorage);
|
|
36
|
+
|
|
37
|
+
// Mock document.cookie for cookie tests
|
|
38
|
+
let cookieJar = '';
|
|
39
|
+
Object.defineProperty(document, 'cookie', {
|
|
40
|
+
get: () => cookieJar,
|
|
41
|
+
set: (v: string) => {
|
|
42
|
+
// Simple cookie jar: parse and store
|
|
43
|
+
const [pair] = v.split(';');
|
|
44
|
+
const [name, val] = pair.split('=');
|
|
45
|
+
if (!val || v.includes('max-age=0') || v.includes('Expires=Thu, 01 Jan 1970')) {
|
|
46
|
+
// Delete cookie
|
|
47
|
+
cookieJar = cookieJar
|
|
48
|
+
.split('; ')
|
|
49
|
+
.filter((c) => !c.startsWith(`${name}=`))
|
|
50
|
+
.join('; ');
|
|
51
|
+
} else {
|
|
52
|
+
// Set cookie
|
|
53
|
+
const existing = cookieJar.split('; ').filter((c) => !c.startsWith(`${name}=`));
|
|
54
|
+
existing.push(`${name}=${val}`);
|
|
55
|
+
cookieJar = existing.filter(Boolean).join('; ');
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
configurable: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
vi.mock('../utils/cookies', () => ({
|
|
62
|
+
getCsrfToken: vi.fn(() => 'test-csrf'),
|
|
63
|
+
deleteCookie: vi.fn(),
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const {
|
|
67
|
+
signInWithCredentials,
|
|
68
|
+
registerAccount,
|
|
69
|
+
signOut,
|
|
70
|
+
authFetch,
|
|
71
|
+
refreshAccessToken,
|
|
72
|
+
setAccessToken,
|
|
73
|
+
getAccessToken,
|
|
74
|
+
setOnSessionExpired,
|
|
75
|
+
setRememberMe,
|
|
76
|
+
} = await import('../client/functions');
|
|
77
|
+
|
|
78
|
+
const { deleteCookie } = await import('../utils/cookies');
|
|
79
|
+
|
|
80
|
+
function makeJwt(payload: object): string {
|
|
81
|
+
const encode = (obj: object) =>
|
|
82
|
+
btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
|
|
83
|
+
return `${encode({ alg: 'HS256' })}.${encode(payload)}.sig`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, user_id: '1' });
|
|
87
|
+
|
|
88
|
+
describe('CSRF token on signin/register', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
mockSessionStorage.clear();
|
|
92
|
+
mockLocalStorage.clear();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('signInWithCredentials sends X-CSRFToken header', async () => {
|
|
96
|
+
mockFetch.mockImplementation((url: string) => {
|
|
97
|
+
if (url.includes('/csrf/')) {
|
|
98
|
+
return Promise.resolve({ ok: true });
|
|
99
|
+
}
|
|
100
|
+
return Promise.resolve({
|
|
101
|
+
ok: true,
|
|
102
|
+
status: 200,
|
|
103
|
+
json: async () => ({ access: VALID_TOKEN, user: { id: '1', email: 'a@b.com' } }),
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await signInWithCredentials('test@test.com', 'password');
|
|
108
|
+
|
|
109
|
+
// Find the token endpoint call (not the csrf call)
|
|
110
|
+
const tokenCall = mockFetch.mock.calls.find(
|
|
111
|
+
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/auth/token/') && !(c[0] as string).includes('csrf') && !(c[0] as string).includes('refresh')
|
|
112
|
+
);
|
|
113
|
+
expect(tokenCall).toBeDefined();
|
|
114
|
+
const headers = tokenCall![1]?.headers;
|
|
115
|
+
expect(headers['X-CSRFToken']).toBe('test-csrf');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('registerAccount sends X-CSRFToken header', async () => {
|
|
119
|
+
mockFetch.mockImplementation((url: string) => {
|
|
120
|
+
if (url.includes('/csrf/')) {
|
|
121
|
+
return Promise.resolve({ ok: true });
|
|
122
|
+
}
|
|
123
|
+
return Promise.resolve({
|
|
124
|
+
ok: true,
|
|
125
|
+
status: 200,
|
|
126
|
+
json: async () => ({ access: VALID_TOKEN, user: { id: '1', email: 'a@b.com' } }),
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await registerAccount({
|
|
131
|
+
email: 'new@test.com',
|
|
132
|
+
password: 'securepassword',
|
|
133
|
+
passwordConfirm: 'securepassword',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const registerCall = mockFetch.mock.calls.find(
|
|
137
|
+
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/auth/register/')
|
|
138
|
+
);
|
|
139
|
+
expect(registerCall).toBeDefined();
|
|
140
|
+
const headers = registerCall![1]?.headers;
|
|
141
|
+
expect(headers['X-CSRFToken']).toBe('test-csrf');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('authFetch session expiration on unrecoverable 401', () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
vi.clearAllMocks();
|
|
148
|
+
mockSessionStorage.clear();
|
|
149
|
+
mockLocalStorage.clear();
|
|
150
|
+
setAccessToken(VALID_TOKEN);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
setOnSessionExpired(null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('calls onSessionExpired callback when refresh fails', async () => {
|
|
158
|
+
const onExpired = vi.fn();
|
|
159
|
+
setOnSessionExpired(onExpired);
|
|
160
|
+
|
|
161
|
+
mockFetch.mockImplementation((url: string) => {
|
|
162
|
+
if (typeof url === 'string' && url.includes('token/refresh')) {
|
|
163
|
+
return Promise.resolve({
|
|
164
|
+
ok: false,
|
|
165
|
+
status: 401,
|
|
166
|
+
json: async () => ({}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (typeof url === 'string' && url.includes('/csrf/')) {
|
|
170
|
+
return Promise.resolve({ ok: true });
|
|
171
|
+
}
|
|
172
|
+
return Promise.resolve({
|
|
173
|
+
ok: false,
|
|
174
|
+
status: 401,
|
|
175
|
+
json: async () => ({}),
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await authFetch('/api/v1/data/');
|
|
180
|
+
|
|
181
|
+
expect(onExpired).toHaveBeenCalledTimes(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('clears access token when refresh fails on 401', async () => {
|
|
185
|
+
mockFetch.mockImplementation((url: string) => {
|
|
186
|
+
if (typeof url === 'string' && url.includes('token/refresh')) {
|
|
187
|
+
return Promise.resolve({
|
|
188
|
+
ok: false,
|
|
189
|
+
status: 401,
|
|
190
|
+
json: async () => ({}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (typeof url === 'string' && url.includes('/csrf/')) {
|
|
194
|
+
return Promise.resolve({ ok: true });
|
|
195
|
+
}
|
|
196
|
+
return Promise.resolve({
|
|
197
|
+
ok: false,
|
|
198
|
+
status: 401,
|
|
199
|
+
json: async () => ({}),
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await authFetch('/api/v1/data/');
|
|
204
|
+
|
|
205
|
+
expect(getAccessToken()).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('signOut cookie cleanup', () => {
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
vi.clearAllMocks();
|
|
212
|
+
mockSessionStorage.clear();
|
|
213
|
+
mockLocalStorage.clear();
|
|
214
|
+
setAccessToken(VALID_TOKEN);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('clears all auth cookies on signOut', async () => {
|
|
218
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
219
|
+
|
|
220
|
+
await signOut();
|
|
221
|
+
|
|
222
|
+
expect(deleteCookie).toHaveBeenCalledWith('auth_session');
|
|
223
|
+
expect(deleteCookie).toHaveBeenCalledWith('access_token');
|
|
224
|
+
expect(deleteCookie).toHaveBeenCalledWith('csrftoken');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('clears access token from storage', async () => {
|
|
228
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
229
|
+
|
|
230
|
+
await signOut();
|
|
231
|
+
|
|
232
|
+
expect(getAccessToken()).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('resets rememberMe flag', async () => {
|
|
236
|
+
setRememberMe(true);
|
|
237
|
+
expect(mockLocalStorage.getItem('auth_remember_me')).toBe('1');
|
|
238
|
+
|
|
239
|
+
mockFetch.mockResolvedValue({ ok: true });
|
|
240
|
+
await signOut();
|
|
241
|
+
|
|
242
|
+
expect(mockLocalStorage.getItem('auth_remember_me')).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('refreshAccessToken clears session on failure', () => {
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
vi.clearAllMocks();
|
|
249
|
+
mockSessionStorage.clear();
|
|
250
|
+
mockLocalStorage.clear();
|
|
251
|
+
setAccessToken(VALID_TOKEN);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('clears token when refresh endpoint returns non-ok', async () => {
|
|
255
|
+
mockFetch.mockImplementation((url: string) => {
|
|
256
|
+
if (typeof url === 'string' && url.includes('/csrf/')) {
|
|
257
|
+
return Promise.resolve({ ok: true });
|
|
258
|
+
}
|
|
259
|
+
return Promise.resolve({
|
|
260
|
+
ok: false,
|
|
261
|
+
status: 401,
|
|
262
|
+
json: async () => ({ detail: 'Token expired' }),
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const result = await refreshAccessToken();
|
|
267
|
+
|
|
268
|
+
expect(result).toBeNull();
|
|
269
|
+
expect(getAccessToken()).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for getAccessToken / setAccessToken using sessionStorage.
|
|
2
|
+
* Tests for getAccessToken / setAccessToken using sessionStorage and localStorage.
|
|
3
3
|
* Regression for fund-your-startup-fe28 (access token was in module-level global).
|
|
4
4
|
*/
|
|
5
5
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
6
|
-
import { getAccessToken, setAccessToken } from '../client/functions';
|
|
6
|
+
import { getAccessToken, setAccessToken, setRememberMe } from '../client/functions';
|
|
7
7
|
|
|
8
|
-
describe('Token storage (sessionStorage)', () => {
|
|
8
|
+
describe('Token storage (sessionStorage — default)', () => {
|
|
9
9
|
beforeEach(() => {
|
|
10
|
-
// Clear sessionStorage before each test
|
|
11
10
|
sessionStorage.clear();
|
|
12
|
-
|
|
11
|
+
localStorage.clear();
|
|
12
|
+
setRememberMe(false);
|
|
13
13
|
setAccessToken(null);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it('stores the token in sessionStorage, not a module-level global', () => {
|
|
17
17
|
setAccessToken('tok-abc123');
|
|
18
18
|
|
|
19
|
-
// Value is readable via the getter
|
|
20
19
|
expect(getAccessToken()).toBe('tok-abc123');
|
|
21
|
-
|
|
22
|
-
// Value is also in sessionStorage (not just a module variable)
|
|
23
20
|
expect(sessionStorage.getItem('auth_access_token')).toBe('tok-abc123');
|
|
24
21
|
});
|
|
25
22
|
|
|
@@ -44,10 +41,7 @@ describe('Token storage (sessionStorage)', () => {
|
|
|
44
41
|
});
|
|
45
42
|
|
|
46
43
|
it('reads fresh value from sessionStorage (survives module reload simulation)', () => {
|
|
47
|
-
// Simulate token set by a previous page load that wrote to sessionStorage
|
|
48
44
|
sessionStorage.setItem('auth_access_token', 'pre-existing-token');
|
|
49
|
-
|
|
50
|
-
// getAccessToken reads from sessionStorage, not just a cached module variable
|
|
51
45
|
expect(getAccessToken()).toBe('pre-existing-token');
|
|
52
46
|
});
|
|
53
47
|
|
|
@@ -59,3 +53,50 @@ describe('Token storage (sessionStorage)', () => {
|
|
|
59
53
|
expect(sessionStorage.getItem('unrelated_key')).toBe('should-not-matter');
|
|
60
54
|
});
|
|
61
55
|
});
|
|
56
|
+
|
|
57
|
+
describe('Token storage (localStorage — remember me)', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
sessionStorage.clear();
|
|
60
|
+
localStorage.clear();
|
|
61
|
+
setRememberMe(false);
|
|
62
|
+
setAccessToken(null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('stores token in localStorage when rememberMe is enabled', () => {
|
|
66
|
+
setRememberMe(true);
|
|
67
|
+
setAccessToken('persistent-tok');
|
|
68
|
+
|
|
69
|
+
expect(getAccessToken()).toBe('persistent-tok');
|
|
70
|
+
expect(localStorage.getItem('auth_access_token')).toBe('persistent-tok');
|
|
71
|
+
// Should NOT be in sessionStorage
|
|
72
|
+
expect(sessionStorage.getItem('auth_access_token')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reads from localStorage when rememberMe is enabled', () => {
|
|
76
|
+
setRememberMe(true);
|
|
77
|
+
localStorage.setItem('auth_access_token', 'pre-existing');
|
|
78
|
+
|
|
79
|
+
expect(getAccessToken()).toBe('pre-existing');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('clearing token removes from both storages', () => {
|
|
83
|
+
setRememberMe(true);
|
|
84
|
+
setAccessToken('tok');
|
|
85
|
+
setAccessToken(null);
|
|
86
|
+
|
|
87
|
+
expect(localStorage.getItem('auth_access_token')).toBeNull();
|
|
88
|
+
expect(sessionStorage.getItem('auth_access_token')).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('switching rememberMe off moves reads back to sessionStorage', () => {
|
|
92
|
+
setRememberMe(true);
|
|
93
|
+
setAccessToken('persistent');
|
|
94
|
+
setRememberMe(false);
|
|
95
|
+
|
|
96
|
+
// Token is still in localStorage but getter reads from sessionStorage now
|
|
97
|
+
expect(getAccessToken()).toBeNull();
|
|
98
|
+
// Put one in sessionStorage
|
|
99
|
+
sessionStorage.setItem('auth_access_token', 'session-tok');
|
|
100
|
+
expect(getAccessToken()).toBe('session-tok');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -1,50 +1,72 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { validatePassword, validateEmail } from '../validation';
|
|
2
|
+
import { validatePassword, validatePasswordConfirm, validateEmail } from '../validation';
|
|
3
3
|
|
|
4
|
-
describe('validatePassword', () => {
|
|
4
|
+
describe('validatePassword — aligned with Django AUTH_PASSWORD_VALIDATORS', () => {
|
|
5
5
|
it('rejects passwords shorter than 8 chars', () => {
|
|
6
|
-
const result = validatePassword('
|
|
6
|
+
const result = validatePassword('abc');
|
|
7
7
|
expect(result.isValid).toBe(false);
|
|
8
|
-
expect(result.
|
|
8
|
+
expect(result.errors).toContainEqual(expect.stringMatching(/8 characters/));
|
|
9
9
|
});
|
|
10
10
|
|
|
11
|
-
it('rejects
|
|
12
|
-
const result = validatePassword('
|
|
11
|
+
it('rejects entirely numeric passwords', () => {
|
|
12
|
+
const result = validatePassword('12345678');
|
|
13
13
|
expect(result.isValid).toBe(false);
|
|
14
|
-
expect(result.
|
|
14
|
+
expect(result.errors).toContainEqual(expect.stringMatching(/entirely numeric/));
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
it('rejects passwords
|
|
18
|
-
const result = validatePassword('
|
|
17
|
+
it('rejects common passwords', () => {
|
|
18
|
+
const result = validatePassword('password1');
|
|
19
19
|
expect(result.isValid).toBe(false);
|
|
20
|
-
expect(result.
|
|
20
|
+
expect(result.errors).toContainEqual(expect.stringMatching(/too common/));
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it('
|
|
24
|
-
const result = validatePassword('
|
|
23
|
+
it('returns multiple errors for passwords that fail multiple rules', () => {
|
|
24
|
+
const result = validatePassword('1234');
|
|
25
25
|
expect(result.isValid).toBe(false);
|
|
26
|
-
expect(result.
|
|
26
|
+
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it('accepts a password with
|
|
29
|
+
it('accepts a valid password with mixed chars', () => {
|
|
30
30
|
const result = validatePassword('Testpassword1');
|
|
31
31
|
expect(result.isValid).toBe(true);
|
|
32
|
-
expect(result.
|
|
32
|
+
expect(result.errors).toHaveLength(0);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
it('accepts
|
|
36
|
-
const result = validatePassword('
|
|
35
|
+
it('accepts lowercase-only password (Django does not require uppercase)', () => {
|
|
36
|
+
const result = validatePassword('myvalidpassword');
|
|
37
|
+
expect(result.isValid).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts password without numbers (Django does not require digits)', () => {
|
|
41
|
+
const result = validatePassword('myvalidpassword');
|
|
37
42
|
expect(result.isValid).toBe(true);
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
it('accepts the canonical test credential', () => {
|
|
41
|
-
// Ensures test account Testpassword1! passes both frontend and backend rules
|
|
42
46
|
expect(validatePassword('Testpassword1!').isValid).toBe(true);
|
|
43
|
-
// Without special char — must also pass (no drift from backend)
|
|
44
47
|
expect(validatePassword('Testpassword1').isValid).toBe(true);
|
|
45
48
|
});
|
|
46
49
|
});
|
|
47
50
|
|
|
51
|
+
describe('validatePasswordConfirm', () => {
|
|
52
|
+
it('rejects mismatched passwords', () => {
|
|
53
|
+
const result = validatePasswordConfirm('validpassword', 'different');
|
|
54
|
+
expect(result.isValid).toBe(false);
|
|
55
|
+
expect(result.errors).toContainEqual(expect.stringMatching(/do not match/));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('validates the password after confirming match', () => {
|
|
59
|
+
const result = validatePasswordConfirm('short', 'short');
|
|
60
|
+
expect(result.isValid).toBe(false);
|
|
61
|
+
expect(result.errors).toContainEqual(expect.stringMatching(/8 characters/));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('accepts matching valid passwords', () => {
|
|
65
|
+
const result = validatePasswordConfirm('validpassword', 'validpassword');
|
|
66
|
+
expect(result.isValid).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
48
70
|
describe('validateEmail', () => {
|
|
49
71
|
it('accepts valid email', () => {
|
|
50
72
|
expect(validateEmail('user@example.com')).toBe(true);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ReactNode,
|
|
14
14
|
} from 'react';
|
|
15
15
|
import { AuthClient } from './auth-client';
|
|
16
|
+
import { setOnSessionExpired } from './functions';
|
|
16
17
|
import type { AuthConfig, AuthState, Session, AuthUser } from '../types';
|
|
17
18
|
|
|
18
19
|
interface AuthContextValue extends AuthState {
|
|
@@ -62,19 +63,23 @@ export function AuthProvider({
|
|
|
62
63
|
}
|
|
63
64
|
}, [authClient, initialSession]);
|
|
64
65
|
|
|
65
|
-
// Session expiration handler
|
|
66
|
+
// Session expiration handler — covers both AuthClient timer and authFetch 401
|
|
66
67
|
useEffect(() => {
|
|
67
|
-
const
|
|
68
|
-
config.onSessionExpired = () => {
|
|
68
|
+
const handleExpired = () => {
|
|
69
69
|
setState({
|
|
70
70
|
session: null,
|
|
71
71
|
isLoading: false,
|
|
72
72
|
isAuthenticated: false,
|
|
73
73
|
});
|
|
74
|
-
|
|
74
|
+
config.onSessionExpired?.();
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
+
config.onSessionExpired = handleExpired;
|
|
78
|
+
// Wire up the functional API's session expiration callback
|
|
79
|
+
setOnSessionExpired(handleExpired);
|
|
80
|
+
|
|
77
81
|
return () => {
|
|
82
|
+
setOnSessionExpired(null);
|
|
78
83
|
authClient.destroy();
|
|
79
84
|
};
|
|
80
85
|
}, [authClient, config]);
|
package/src/client/functions.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* No Next.js dependency.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getCsrfToken } from '../utils/cookies';
|
|
9
|
+
import { getCsrfToken, deleteCookie } from '../utils/cookies';
|
|
10
|
+
import { decodeToken } from '../utils/token';
|
|
10
11
|
|
|
11
12
|
// --- Types ---
|
|
12
13
|
|
|
@@ -22,6 +23,16 @@ export interface AuthUser {
|
|
|
22
23
|
isEmailVerified?: boolean;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Callback invoked when an unrecoverable 401 is detected (refresh failed). */
|
|
27
|
+
export type OnSessionExpiredCallback = () => void;
|
|
28
|
+
|
|
29
|
+
let _onSessionExpired: OnSessionExpiredCallback | null = null;
|
|
30
|
+
|
|
31
|
+
/** Register a callback for unrecoverable 401s (typically set by AuthProvider). */
|
|
32
|
+
export function setOnSessionExpired(cb: OnSessionExpiredCallback | null): void {
|
|
33
|
+
_onSessionExpired = cb;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
// --- Endpoint paths (Django backend defaults) ---
|
|
26
37
|
|
|
27
38
|
const API_BASE = '/api/v1';
|
|
@@ -70,39 +81,64 @@ export function resolveAuthUrl(path: string): string {
|
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
// --- Token storage ---
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// module-level variable for SSR environments
|
|
84
|
+
// By default uses sessionStorage (scoped to tab, cleared on close).
|
|
85
|
+
// When `setRememberMe(true)` is called, switches to localStorage for persistence.
|
|
86
|
+
// Falls back to a module-level variable for SSR environments.
|
|
76
87
|
|
|
77
88
|
const TOKEN_STORAGE_KEY = 'auth_access_token';
|
|
89
|
+
const REMEMBER_ME_KEY = 'auth_remember_me';
|
|
78
90
|
|
|
79
91
|
let _memToken: string | null = null;
|
|
80
92
|
|
|
81
|
-
function
|
|
93
|
+
function _storageAvailable(type: 'sessionStorage' | 'localStorage'): boolean {
|
|
82
94
|
try {
|
|
83
|
-
return typeof window !== 'undefined' && !!window
|
|
95
|
+
return typeof window !== 'undefined' && !!window[type];
|
|
84
96
|
} catch {
|
|
85
97
|
return false;
|
|
86
98
|
}
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
return
|
|
101
|
+
function _isRememberMe(): boolean {
|
|
102
|
+
if (_storageAvailable('localStorage')) {
|
|
103
|
+
return localStorage.getItem(REMEMBER_ME_KEY) === '1';
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Enable/disable persistent token storage across browser sessions. */
|
|
109
|
+
export function setRememberMe(enabled: boolean): void {
|
|
110
|
+
if (_storageAvailable('localStorage')) {
|
|
111
|
+
if (enabled) {
|
|
112
|
+
localStorage.setItem(REMEMBER_ME_KEY, '1');
|
|
113
|
+
} else {
|
|
114
|
+
localStorage.removeItem(REMEMBER_ME_KEY);
|
|
115
|
+
}
|
|
92
116
|
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _getStorage(): Storage | null {
|
|
120
|
+
if (_isRememberMe() && _storageAvailable('localStorage')) return localStorage;
|
|
121
|
+
if (_storageAvailable('sessionStorage')) return sessionStorage;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function getAccessToken(): string | null {
|
|
126
|
+
const storage = _getStorage();
|
|
127
|
+
if (storage) return storage.getItem(TOKEN_STORAGE_KEY);
|
|
93
128
|
return _memToken;
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
export function setAccessToken(token: string | null): void {
|
|
97
|
-
|
|
132
|
+
const storage = _getStorage();
|
|
133
|
+
if (storage) {
|
|
98
134
|
if (token === null) {
|
|
99
|
-
|
|
135
|
+
storage.removeItem(TOKEN_STORAGE_KEY);
|
|
136
|
+
// Also clear from the other storage in case rememberMe was toggled
|
|
137
|
+
if (_storageAvailable('sessionStorage')) sessionStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
138
|
+
if (_storageAvailable('localStorage')) localStorage.removeItem(TOKEN_STORAGE_KEY);
|
|
100
139
|
} else {
|
|
101
|
-
|
|
140
|
+
storage.setItem(TOKEN_STORAGE_KEY, token);
|
|
102
141
|
}
|
|
103
|
-
// Sync to a cookie so Next.js middleware can check auth state.
|
|
104
|
-
// Vercel rewrites don't reliably pass through HttpOnly Set-Cookie
|
|
105
|
-
// headers from the Django backend, so we set our own.
|
|
106
142
|
_syncAuthCookie(token);
|
|
107
143
|
return;
|
|
108
144
|
}
|
|
@@ -111,11 +147,22 @@ export function setAccessToken(token: string | null): void {
|
|
|
111
147
|
|
|
112
148
|
const AUTH_COOKIE_NAME = 'auth_session';
|
|
113
149
|
|
|
150
|
+
/** Derive cookie max-age from JWT exp claim instead of hardcoding. */
|
|
151
|
+
function _getTokenMaxAge(token: string): number {
|
|
152
|
+
const payload = decodeToken(token);
|
|
153
|
+
if (payload?.exp) {
|
|
154
|
+
const secondsLeft = payload.exp - Math.floor(Date.now() / 1000);
|
|
155
|
+
if (secondsLeft > 0) return secondsLeft;
|
|
156
|
+
}
|
|
157
|
+
return 3600; // fallback 1hr — gives the 25min refresh interval plenty of headroom
|
|
158
|
+
}
|
|
159
|
+
|
|
114
160
|
function _syncAuthCookie(token: string | null): void {
|
|
115
161
|
if (typeof document === 'undefined') return;
|
|
116
162
|
if (token) {
|
|
163
|
+
const maxAge = _getTokenMaxAge(token);
|
|
117
164
|
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
|
118
|
-
document.cookie = `${AUTH_COOKIE_NAME}=${token}; path=/; max-age
|
|
165
|
+
document.cookie = `${AUTH_COOKIE_NAME}=${token}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
|
|
119
166
|
} else {
|
|
120
167
|
document.cookie = `${AUTH_COOKIE_NAME}=; path=/; max-age=0`;
|
|
121
168
|
}
|
|
@@ -183,9 +230,14 @@ function parseAuthResponse(data: unknown): { access?: string; user?: AuthUser }
|
|
|
183
230
|
// --- Auth functions ---
|
|
184
231
|
|
|
185
232
|
export async function signInWithCredentials(email: string, password: string) {
|
|
233
|
+
await fetchCsrfToken();
|
|
234
|
+
const csrfToken = getCsrfToken();
|
|
186
235
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN), {
|
|
187
236
|
method: 'POST',
|
|
188
|
-
headers: {
|
|
237
|
+
headers: {
|
|
238
|
+
'Content-Type': 'application/json',
|
|
239
|
+
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
|
240
|
+
},
|
|
189
241
|
credentials: 'include',
|
|
190
242
|
body: JSON.stringify({ email, password }),
|
|
191
243
|
});
|
|
@@ -221,9 +273,14 @@ export async function registerAccount(payload: {
|
|
|
221
273
|
const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
|
|
222
274
|
const lastFromName = rest.length ? rest.join(' ') : undefined;
|
|
223
275
|
|
|
276
|
+
await fetchCsrfToken();
|
|
277
|
+
const csrfToken = getCsrfToken();
|
|
224
278
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.REGISTER), {
|
|
225
279
|
method: 'POST',
|
|
226
|
-
headers: {
|
|
280
|
+
headers: {
|
|
281
|
+
'Content-Type': 'application/json',
|
|
282
|
+
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
|
283
|
+
},
|
|
227
284
|
credentials: 'include',
|
|
228
285
|
body: JSON.stringify({
|
|
229
286
|
email: payload.email,
|
|
@@ -367,20 +424,35 @@ export async function completeGoogleOAuth(code: string, state: string) {
|
|
|
367
424
|
|
|
368
425
|
async function fetchCsrfToken(): Promise<void> {
|
|
369
426
|
if (getCsrfToken()) return;
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
427
|
+
const maxAttempts = 3;
|
|
428
|
+
let lastError: unknown;
|
|
429
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
430
|
+
try {
|
|
431
|
+
await fetch(resolveAuthUrl(`${API_BASE}/auth/csrf/`), {
|
|
432
|
+
credentials: 'include',
|
|
433
|
+
cache: 'no-store',
|
|
434
|
+
});
|
|
435
|
+
if (getCsrfToken()) return;
|
|
436
|
+
} catch (err) {
|
|
437
|
+
lastError = err;
|
|
438
|
+
}
|
|
439
|
+
if (attempt < maxAttempts - 1) {
|
|
440
|
+
await new Promise(r => setTimeout(r, 500));
|
|
441
|
+
}
|
|
377
442
|
}
|
|
443
|
+
throw new Error(
|
|
444
|
+
`[auth] CSRF token fetch failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError ?? 'no token set')}`
|
|
445
|
+
);
|
|
378
446
|
}
|
|
379
447
|
|
|
380
448
|
export async function refreshAccessToken(): Promise<string | null> {
|
|
381
449
|
await fetchCsrfToken();
|
|
382
450
|
const csrfToken = getCsrfToken();
|
|
383
|
-
if (!csrfToken)
|
|
451
|
+
if (!csrfToken) {
|
|
452
|
+
console.warn('[auth] No CSRF token available — cannot refresh. Clearing session.');
|
|
453
|
+
setAccessToken(null);
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
384
456
|
|
|
385
457
|
const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
|
|
386
458
|
method: 'POST',
|
|
@@ -394,6 +466,7 @@ export async function refreshAccessToken(): Promise<string | null> {
|
|
|
394
466
|
const data = await response.json().catch(() => ({}));
|
|
395
467
|
|
|
396
468
|
if (!response.ok) {
|
|
469
|
+
setAccessToken(null);
|
|
397
470
|
return null;
|
|
398
471
|
}
|
|
399
472
|
|
|
@@ -445,6 +518,11 @@ export async function signOut(): Promise<void> {
|
|
|
445
518
|
});
|
|
446
519
|
} finally {
|
|
447
520
|
setAccessToken(null);
|
|
521
|
+
setRememberMe(false);
|
|
522
|
+
// Clear all auth-related cookies
|
|
523
|
+
deleteCookie(AUTH_COOKIE_NAME);
|
|
524
|
+
deleteCookie('access_token');
|
|
525
|
+
deleteCookie('csrftoken');
|
|
448
526
|
}
|
|
449
527
|
}
|
|
450
528
|
|
|
@@ -481,16 +559,25 @@ export async function authFetch(
|
|
|
481
559
|
|
|
482
560
|
if (response.status === 401) {
|
|
483
561
|
const refreshed = await _refreshOnce();
|
|
562
|
+
const retryHeaders = new Headers(init.headers || {});
|
|
563
|
+
|
|
484
564
|
if (refreshed) {
|
|
485
|
-
const retryHeaders = new Headers(init.headers || {});
|
|
486
565
|
retryHeaders.set('Authorization', `Bearer ${refreshed}`);
|
|
566
|
+
}
|
|
487
567
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
568
|
+
const retryResponse = await fetch(resolvedInput, {
|
|
569
|
+
...init,
|
|
570
|
+
headers: retryHeaders,
|
|
571
|
+
credentials: init.credentials ?? 'include',
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
if (!refreshed || retryResponse.status === 401) {
|
|
575
|
+
// Refresh failed or retried request still unauthorized — session is dead.
|
|
576
|
+
setAccessToken(null);
|
|
577
|
+
_onSessionExpired?.();
|
|
493
578
|
}
|
|
579
|
+
|
|
580
|
+
return retryResponse;
|
|
494
581
|
}
|
|
495
582
|
|
|
496
583
|
return response;
|
package/src/client/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { AuthClient } from './auth-client';
|
|
2
2
|
export { AuthProvider, useAuthContext } from './auth-context';
|
|
3
|
-
export { useAuth, type UseAuthReturn } from './use-auth';
|
|
3
|
+
export { useAuth, useRequireAuth, type UseAuthReturn, type UseRequireAuthReturn, type UseRequireAuthOptions } from './use-auth';
|
|
4
4
|
export { usePermissions, type UsePermissionsReturn } from './use-permissions';
|
|
5
5
|
export * from './functions';
|
package/src/client/use-auth.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* React
|
|
2
|
+
* React hooks for authentication
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
'use client';
|
|
6
6
|
|
|
7
|
+
import { useEffect } from 'react';
|
|
7
8
|
import { useAuthContext } from './auth-context';
|
|
8
9
|
import type { AuthUser, Session } from '../types';
|
|
9
10
|
|
|
@@ -43,3 +44,47 @@ export function useAuth(): UseAuthReturn {
|
|
|
43
44
|
getAccessToken,
|
|
44
45
|
};
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
export interface UseRequireAuthOptions {
|
|
49
|
+
redirectTo?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface UseRequireAuthReturn {
|
|
53
|
+
user: AuthUser;
|
|
54
|
+
session: Session;
|
|
55
|
+
isLoading: boolean;
|
|
56
|
+
logout: () => Promise<void>;
|
|
57
|
+
refreshUser: () => Promise<void>;
|
|
58
|
+
getAccessToken: () => Promise<string | null>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hook that redirects unauthenticated users to login.
|
|
63
|
+
* Returns typed non-null user/session once authenticated.
|
|
64
|
+
* While loading or redirecting, returns isLoading: true with placeholder values.
|
|
65
|
+
*/
|
|
66
|
+
export function useRequireAuth(
|
|
67
|
+
options: UseRequireAuthOptions = {}
|
|
68
|
+
): UseRequireAuthReturn {
|
|
69
|
+
const { redirectTo = '/auth/signin' } = options;
|
|
70
|
+
const auth = useAuth();
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!auth.isLoading && !auth.isAuthenticated) {
|
|
74
|
+
const callbackUrl = typeof window !== 'undefined' ? window.location.pathname : '/';
|
|
75
|
+
const url = `${redirectTo}?callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
|
76
|
+
if (typeof window !== 'undefined') {
|
|
77
|
+
window.location.href = url;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}, [auth.isLoading, auth.isAuthenticated, redirectTo]);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
user: auth.user as AuthUser,
|
|
84
|
+
session: auth.session as Session,
|
|
85
|
+
isLoading: auth.isLoading || !auth.isAuthenticated,
|
|
86
|
+
logout: auth.logout,
|
|
87
|
+
refreshUser: auth.refreshUser,
|
|
88
|
+
getAccessToken: auth.getAccessToken,
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -17,10 +17,13 @@ export {
|
|
|
17
17
|
AuthProvider,
|
|
18
18
|
useAuthContext,
|
|
19
19
|
useAuth,
|
|
20
|
+
useRequireAuth,
|
|
20
21
|
usePermissions,
|
|
21
22
|
resolveAuthUrl,
|
|
22
23
|
getAccessToken,
|
|
23
24
|
setAccessToken,
|
|
25
|
+
setRememberMe,
|
|
26
|
+
setOnSessionExpired,
|
|
24
27
|
signInWithCredentials,
|
|
25
28
|
registerAccount,
|
|
26
29
|
requestPasswordReset,
|
|
@@ -36,6 +39,6 @@ export {
|
|
|
36
39
|
hasPermission,
|
|
37
40
|
hasGroup,
|
|
38
41
|
} from './client';
|
|
39
|
-
export type { UseAuthReturn, UsePermissionsReturn } from './client';
|
|
42
|
+
export type { UseAuthReturn, UseRequireAuthReturn, UseRequireAuthOptions, UsePermissionsReturn } from './client';
|
|
40
43
|
|
|
41
44
|
// DO NOT export './server' here - it uses next/headers and must be imported explicitly via '@startsimpli/auth/server'
|
package/src/validation/index.ts
CHANGED
|
@@ -6,29 +6,47 @@ export function validateEmail(email: string): boolean {
|
|
|
6
6
|
|
|
7
7
|
export interface PasswordValidationResult {
|
|
8
8
|
isValid: boolean;
|
|
9
|
+
errors: string[];
|
|
10
|
+
/** First error message, for backward compatibility with `.error` consumers. */
|
|
9
11
|
error?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
//
|
|
14
|
+
// Common passwords subset — Django uses a 20k list; we check the most obvious ones
|
|
15
|
+
// client-side and let the backend catch the rest.
|
|
16
|
+
const COMMON_PASSWORDS = new Set([
|
|
17
|
+
'password', 'password1', '12345678', '123456789', '1234567890',
|
|
18
|
+
'qwerty123', 'abcdefgh', 'letmein12', 'welcome1', 'admin123',
|
|
19
|
+
'iloveyou1', 'sunshine1', 'princess1', 'football1', 'monkey123',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonical password rules aligned with Django AUTH_PASSWORD_VALIDATORS:
|
|
24
|
+
* - MinimumLengthValidator: min_length=8
|
|
25
|
+
* - NumericPasswordValidator: not entirely numeric
|
|
26
|
+
* - CommonPasswordValidator: not in common list (partial client-side check)
|
|
27
|
+
*
|
|
28
|
+
* Django also runs UserAttributeSimilarityValidator server-side (needs user data).
|
|
29
|
+
* We don't enforce uppercase/special chars — Django doesn't either.
|
|
30
|
+
*/
|
|
13
31
|
export function validatePassword(password: string): PasswordValidationResult {
|
|
32
|
+
const errors: string[] = [];
|
|
33
|
+
|
|
14
34
|
if (password.length < 8) {
|
|
15
|
-
|
|
35
|
+
errors.push('Password must be at least 8 characters');
|
|
16
36
|
}
|
|
17
|
-
if (
|
|
18
|
-
|
|
37
|
+
if (/^\d+$/.test(password)) {
|
|
38
|
+
errors.push('Password cannot be entirely numeric');
|
|
19
39
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
40
|
+
if (COMMON_PASSWORDS.has(password.toLowerCase())) {
|
|
41
|
+
errors.push('This password is too common');
|
|
22
42
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
return { isValid: true };
|
|
43
|
+
|
|
44
|
+
return { isValid: errors.length === 0, errors, error: errors[0] };
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
export function validatePasswordConfirm(password: string, confirm: string): PasswordValidationResult {
|
|
30
48
|
if (password !== confirm) {
|
|
31
|
-
return { isValid: false, error: 'Passwords do not match' };
|
|
49
|
+
return { isValid: false, errors: ['Passwords do not match'], error: 'Passwords do not match' };
|
|
32
50
|
}
|
|
33
51
|
return validatePassword(password);
|
|
34
52
|
}
|