@startsimpli/auth 0.4.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
- // Reset to null so tests are independent
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('Ab1');
6
+ const result = validatePassword('abc');
7
7
  expect(result.isValid).toBe(false);
8
- expect(result.error).toMatch(/8 characters/);
8
+ expect(result.errors).toContainEqual(expect.stringMatching(/8 characters/));
9
9
  });
10
10
 
11
- it('rejects passwords with no uppercase letter', () => {
12
- const result = validatePassword('abcdefg1');
11
+ it('rejects entirely numeric passwords', () => {
12
+ const result = validatePassword('12345678');
13
13
  expect(result.isValid).toBe(false);
14
- expect(result.error).toMatch(/uppercase/i);
14
+ expect(result.errors).toContainEqual(expect.stringMatching(/entirely numeric/));
15
15
  });
16
16
 
17
- it('rejects passwords with no lowercase letter', () => {
18
- const result = validatePassword('ABCDEFG1');
17
+ it('rejects common passwords', () => {
18
+ const result = validatePassword('password1');
19
19
  expect(result.isValid).toBe(false);
20
- expect(result.error).toMatch(/lowercase/i);
20
+ expect(result.errors).toContainEqual(expect.stringMatching(/too common/));
21
21
  });
22
22
 
23
- it('rejects passwords with no number', () => {
24
- const result = validatePassword('Abcdefgh');
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.error).toMatch(/number/i);
26
+ expect(result.errors.length).toBeGreaterThanOrEqual(2);
27
27
  });
28
28
 
29
- it('accepts a password with uppercase, lowercase, and number — no special char required', () => {
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.error).toBeUndefined();
32
+ expect(result.errors).toHaveLength(0);
33
33
  });
34
34
 
35
- it('accepts a password that also has a special character', () => {
36
- const result = validatePassword('Testpassword1!');
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 originalOnExpired = config.onSessionExpired;
68
- config.onSessionExpired = () => {
68
+ const handleExpired = () => {
69
69
  setState({
70
70
  session: null,
71
71
  isLoading: false,
72
72
  isAuthenticated: false,
73
73
  });
74
- originalOnExpired?.();
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]);
@@ -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
- // Uses sessionStorage when available (browser) so the token is scoped to the
74
- // current tab and cleared automatically when the tab closes. Falls back to a
75
- // module-level variable for SSR environments where sessionStorage is absent.
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 _sessionStorageAvailable(): boolean {
93
+ function _storageAvailable(type: 'sessionStorage' | 'localStorage'): boolean {
82
94
  try {
83
- return typeof window !== 'undefined' && !!window.sessionStorage;
95
+ return typeof window !== 'undefined' && !!window[type];
84
96
  } catch {
85
97
  return false;
86
98
  }
87
99
  }
88
100
 
89
- export function getAccessToken(): string | null {
90
- if (_sessionStorageAvailable()) {
91
- return sessionStorage.getItem(TOKEN_STORAGE_KEY);
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
- if (_sessionStorageAvailable()) {
132
+ const storage = _getStorage();
133
+ if (storage) {
98
134
  if (token === null) {
99
- sessionStorage.removeItem(TOKEN_STORAGE_KEY);
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
- sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
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=1800; SameSite=Lax${secure}`;
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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
- try {
371
- await fetch(resolveAuthUrl(`${API_BASE}/auth/csrf/`), {
372
- credentials: 'include',
373
- cache: 'no-store',
374
- });
375
- } catch (err) {
376
- console.warn('[auth] CSRF token fetch failed — token refresh will fail:', err)
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) return null;
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
- return fetch(resolvedInput, {
489
- ...init,
490
- headers: retryHeaders,
491
- credentials: init.credentials ?? 'include',
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;
@@ -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';
@@ -1,9 +1,10 @@
1
1
  /**
2
- * React hook for authentication
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'
@@ -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
- // Canonical password rules for all StartSimpli apps
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
- return { isValid: false, error: 'Password must be at least 8 characters' };
35
+ errors.push('Password must be at least 8 characters');
16
36
  }
17
- if (!/[A-Z]/.test(password)) {
18
- return { isValid: false, error: 'Password must contain at least one uppercase letter' };
37
+ if (/^\d+$/.test(password)) {
38
+ errors.push('Password cannot be entirely numeric');
19
39
  }
20
- if (!/[a-z]/.test(password)) {
21
- return { isValid: false, error: 'Password must contain at least one lowercase letter' };
40
+ if (COMMON_PASSWORDS.has(password.toLowerCase())) {
41
+ errors.push('This password is too common');
22
42
  }
23
- if (!/[0-9]/.test(password)) {
24
- return { isValid: false, error: 'Password must contain at least one number' };
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
  }