@startsimpli/auth 0.1.0 → 0.1.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.
Files changed (39) hide show
  1. package/dist/chunk-CDNZRZ7Q.mjs +767 -0
  2. package/dist/chunk-CDNZRZ7Q.mjs.map +1 -0
  3. package/dist/chunk-S6J5FYQY.mjs +134 -0
  4. package/dist/chunk-S6J5FYQY.mjs.map +1 -0
  5. package/dist/chunk-TA46ASDJ.mjs +37 -0
  6. package/dist/chunk-TA46ASDJ.mjs.map +1 -0
  7. package/dist/client/index.d.mts +175 -0
  8. package/dist/client/index.d.ts +175 -0
  9. package/dist/client/index.js +858 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/index.mjs +5 -0
  12. package/dist/client/index.mjs.map +1 -0
  13. package/dist/index.d.mts +68 -0
  14. package/dist/index.d.ts +68 -0
  15. package/dist/index.js +971 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/index.mjs +5 -0
  18. package/dist/index.mjs.map +1 -0
  19. package/dist/server/index.d.mts +83 -0
  20. package/dist/server/index.d.ts +83 -0
  21. package/dist/server/index.js +242 -0
  22. package/dist/server/index.js.map +1 -0
  23. package/dist/server/index.mjs +191 -0
  24. package/dist/server/index.mjs.map +1 -0
  25. package/dist/types/index.d.mts +209 -0
  26. package/dist/types/index.d.ts +209 -0
  27. package/dist/types/index.js +43 -0
  28. package/dist/types/index.js.map +1 -0
  29. package/dist/types/index.mjs +3 -0
  30. package/dist/types/index.mjs.map +1 -0
  31. package/package.json +50 -18
  32. package/src/__tests__/auth-client.test.ts +125 -0
  33. package/src/__tests__/auth-fetch.test.ts +128 -0
  34. package/src/__tests__/token-storage.test.ts +61 -0
  35. package/src/__tests__/validation.test.ts +60 -0
  36. package/src/client/auth-client.ts +11 -1
  37. package/src/client/functions.ts +83 -14
  38. package/src/types/index.ts +100 -0
  39. package/src/utils/validation.ts +190 -0
@@ -0,0 +1,3 @@
1
+ export { EmailErrorCode, PasswordErrorCode, ROLE_HIERARCHY, ValidationErrorCode, hasRolePermission } from '../chunk-TA46ASDJ.mjs';
2
+ //# sourceMappingURL=index.mjs.map
3
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.mjs"}
package/package.json CHANGED
@@ -1,37 +1,65 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
- "main": "./src/index.ts",
6
- "types": "./src/index.ts",
7
- "files": ["src"],
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.mjs",
11
+ "require": "./dist/index.js"
12
+ },
13
+ "./client": {
14
+ "types": "./dist/client/index.d.ts",
15
+ "import": "./dist/client/index.mjs",
16
+ "require": "./dist/client/index.js"
17
+ },
18
+ "./server": {
19
+ "types": "./dist/server/index.d.ts",
20
+ "import": "./dist/server/index.mjs",
21
+ "require": "./dist/server/index.js"
22
+ },
23
+ "./types": {
24
+ "types": "./dist/types/index.d.ts",
25
+ "import": "./dist/types/index.mjs",
26
+ "require": "./dist/types/index.js"
27
+ },
28
+ "./email": {
29
+ "types": "./dist/email/index.d.ts",
30
+ "import": "./dist/email/index.mjs",
31
+ "require": "./dist/email/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "src",
36
+ "README.md",
37
+ "dist"
38
+ ],
8
39
  "publishConfig": {
9
40
  "access": "public"
10
41
  },
11
- "exports": {
12
- ".": "./src/index.ts",
13
- "./client": "./src/client/index.ts",
14
- "./server": "./src/server/index.ts",
15
- "./types": "./src/types/index.ts",
16
- "./email": "./src/email/index.ts"
17
- },
18
42
  "scripts": {
43
+ "build": "tsup",
44
+ "dev": "tsup --watch",
45
+ "type-check": "tsc --noEmit",
19
46
  "test": "vitest run",
20
47
  "test:watch": "vitest",
21
48
  "test:coverage": "vitest run --coverage",
22
- "type-check": "tsc --noEmit"
49
+ "clean": "rm -rf dist"
23
50
  },
24
51
  "peerDependencies": {
25
52
  "react": "^18.0.0 || ^19.0.0",
26
- "next": "^14.0.0 || ^15.0.0"
53
+ "next": "^14.0.0 || ^15.0.0 || ^16.0.0"
27
54
  },
28
55
  "devDependencies": {
29
- "@types/react": "^18.3.18",
30
56
  "@types/node": "^20.17.14",
31
- "typescript": "^5.7.3",
32
- "vitest": "^3.0.0",
57
+ "@types/react": "^18.3.18",
33
58
  "@vitest/ui": "^3.0.0",
34
- "happy-dom": "^15.11.7"
59
+ "happy-dom": "^15.11.7",
60
+ "tsup": "^8.5.1",
61
+ "typescript": "^5.7.3",
62
+ "vitest": "^3.0.0"
35
63
  },
36
64
  "keywords": [
37
65
  "authentication",
@@ -39,5 +67,9 @@
39
67
  "nextjs",
40
68
  "django",
41
69
  "startsimpli"
42
- ]
70
+ ],
71
+ "dependencies": {
72
+ "zod": "^4.3.6"
73
+ },
74
+ "module": "./dist/index.mjs"
43
75
  }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { AuthClient } from '../client/auth-client';
3
+
4
+ // Helpers
5
+ function makeToken(exp: number): string {
6
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
7
+ const body = btoa(JSON.stringify({ token_type: 'access', exp, iat: exp - 3600, jti: 'test', user_id: '123' }));
8
+ return `${header}.${body}.signature`;
9
+ }
10
+
11
+ const VALID_TOKEN = makeToken(Math.floor(Date.now() / 1000) + 3600);
12
+
13
+ function makeConfig() {
14
+ return { apiBaseUrl: 'http://localhost:8001' };
15
+ }
16
+
17
+ describe('AuthClient.getCurrentUser', () => {
18
+ beforeEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+
22
+ it('unwraps {"user": {...}} envelope from Django /me/ response', async () => {
23
+ const client = new AuthClient(makeConfig());
24
+
25
+ // Set a session so getAuthHeaders works
26
+ (client as any).session = {
27
+ user: { id: '', email: '', firstName: '', lastName: '', isEmailVerified: false, createdAt: '', updatedAt: '' },
28
+ accessToken: VALID_TOKEN,
29
+ expiresAt: Date.now() + 3600000,
30
+ };
31
+
32
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
33
+ ok: true,
34
+ json: async () => ({
35
+ user: {
36
+ id: 'abc-123',
37
+ email: 'test@example.com',
38
+ first_name: 'Test',
39
+ last_name: 'User',
40
+ is_email_verified: true,
41
+ created_at: '2026-01-01T00:00:00Z',
42
+ updated_at: '2026-01-02T00:00:00Z',
43
+ },
44
+ }),
45
+ }));
46
+
47
+ const user = await client.getCurrentUser();
48
+
49
+ expect(user.id).toBe('abc-123');
50
+ expect(user.email).toBe('test@example.com');
51
+ expect(user.firstName).toBe('Test');
52
+ expect(user.lastName).toBe('User');
53
+ expect(user.isEmailVerified).toBe(true);
54
+ expect(user.createdAt).toBe('2026-01-01T00:00:00Z');
55
+ });
56
+
57
+ it('handles flat response (no wrapper) for forwards-compat', async () => {
58
+ const client = new AuthClient(makeConfig());
59
+
60
+ (client as any).session = {
61
+ user: { id: '', email: '', firstName: '', lastName: '', isEmailVerified: false, createdAt: '', updatedAt: '' },
62
+ accessToken: VALID_TOKEN,
63
+ expiresAt: Date.now() + 3600000,
64
+ };
65
+
66
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
67
+ ok: true,
68
+ json: async () => ({
69
+ id: 'xyz-456',
70
+ email: 'flat@example.com',
71
+ first_name: 'Flat',
72
+ last_name: 'Response',
73
+ is_email_verified: false,
74
+ created_at: '2026-01-01T00:00:00Z',
75
+ updated_at: '2026-01-01T00:00:00Z',
76
+ }),
77
+ }));
78
+
79
+ const user = await client.getCurrentUser();
80
+
81
+ expect(user.id).toBe('xyz-456');
82
+ expect(user.email).toBe('flat@example.com');
83
+ expect(user.firstName).toBe('Flat');
84
+ });
85
+ });
86
+
87
+ describe('AuthClient.login', () => {
88
+ beforeEach(() => {
89
+ vi.restoreAllMocks();
90
+ });
91
+
92
+ it('fetches /me/ when token response has no user and maps fields correctly', async () => {
93
+ const client = new AuthClient(makeConfig());
94
+
95
+ vi.stubGlobal('fetch', vi.fn()
96
+ // First call: token endpoint
97
+ .mockResolvedValueOnce({
98
+ ok: true,
99
+ json: async () => ({ access: VALID_TOKEN }),
100
+ })
101
+ // Second call: /me/ endpoint
102
+ .mockResolvedValueOnce({
103
+ ok: true,
104
+ json: async () => ({
105
+ user: {
106
+ id: 'login-user-id',
107
+ email: 'login@example.com',
108
+ first_name: 'Login',
109
+ last_name: 'Test',
110
+ is_email_verified: false,
111
+ created_at: '2026-01-01T00:00:00Z',
112
+ updated_at: '2026-01-01T00:00:00Z',
113
+ },
114
+ }),
115
+ }),
116
+ );
117
+
118
+ const session = await client.login('login@example.com', 'password');
119
+
120
+ expect(session.user.email).toBe('login@example.com');
121
+ expect(session.user.firstName).toBe('Login');
122
+ expect(session.user.lastName).toBe('Test');
123
+ expect(session.accessToken).toBe(VALID_TOKEN);
124
+ });
125
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tests for authFetch refresh-singleton (uhxu fix).
3
+ * Concurrent 401 responses must share a single refreshAccessToken() call.
4
+ */
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ // We import the module functions directly so we can control mocks around them.
8
+ // The mock must be set up before the module is imported.
9
+
10
+ // Mock fetch globally before importing the module under test
11
+ const mockFetch = vi.fn();
12
+ vi.stubGlobal('fetch', mockFetch);
13
+
14
+ // Mock sessionStorage (available in browser but not in vitest/node)
15
+ const mockSessionStorage = (() => {
16
+ let store: Record<string, string> = {};
17
+ return {
18
+ getItem: (k: string) => store[k] ?? null,
19
+ setItem: (k: string, v: string) => { store[k] = v },
20
+ removeItem: (k: string) => { delete store[k] },
21
+ clear: () => { store = {} },
22
+ };
23
+ })();
24
+ vi.stubGlobal('sessionStorage', mockSessionStorage);
25
+
26
+ // Import after globals are set up
27
+ const {
28
+ authFetch,
29
+ setAccessToken,
30
+ getAccessToken,
31
+ } = await import('../client/functions');
32
+
33
+ function makeJwt(payload: object): string {
34
+ const encode = (obj: object) =>
35
+ btoa(JSON.stringify(obj)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
36
+ return `${encode({ alg: 'HS256' })}.${encode(payload)}.sig`;
37
+ }
38
+
39
+ const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, user_id: '1' });
40
+ const REFRESHED_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200, user_id: '1' });
41
+
42
+ // Stub the CSRF token helper so refreshAccessToken() doesn't fail
43
+ vi.mock('../utils/cookies', () => ({
44
+ getCsrfToken: () => 'test-csrf',
45
+ setCsrfToken: vi.fn(),
46
+ }));
47
+
48
+ // Stub fetchCsrfToken (called inside refreshAccessToken)
49
+ vi.mock('../client/functions', async (importOriginal) => {
50
+ const original = await importOriginal<typeof import('../client/functions')>();
51
+ return {
52
+ ...original,
53
+ fetchCsrfToken: vi.fn().mockResolvedValue(undefined),
54
+ };
55
+ });
56
+
57
+ describe('authFetch — concurrent 401 refresh atomicity (uhxu)', () => {
58
+ beforeEach(() => {
59
+ vi.clearAllMocks();
60
+ mockSessionStorage.clear();
61
+ setAccessToken(VALID_TOKEN);
62
+ });
63
+
64
+ afterEach(() => {
65
+ vi.clearAllMocks();
66
+ });
67
+
68
+ it('calls refreshAccessToken only once when two concurrent requests get 401', async () => {
69
+ let refreshCallCount = 0;
70
+
71
+ mockFetch.mockImplementation((url: string) => {
72
+ // Refresh endpoint
73
+ if (typeof url === 'string' && url.includes('token/refresh')) {
74
+ refreshCallCount++;
75
+ return Promise.resolve({
76
+ ok: true,
77
+ status: 200,
78
+ json: async () => ({ access: REFRESHED_TOKEN }),
79
+ });
80
+ }
81
+ // First call for any request: return 401; second (retry): return 200
82
+ return Promise.resolve({
83
+ ok: false,
84
+ status: 401,
85
+ json: async () => ({}),
86
+ });
87
+ });
88
+
89
+ // Simulate two concurrent requests that both receive 401
90
+ const [r1, r2] = await Promise.all([
91
+ authFetch('/api/v1/data/'),
92
+ authFetch('/api/v1/other/'),
93
+ ]);
94
+
95
+ // Both requests eventually fail (since the mock always returns 401 for non-refresh URLs)
96
+ // but the refresh endpoint was only called once
97
+ expect(refreshCallCount).toBe(1);
98
+ expect(r1.status).toBe(401);
99
+ expect(r2.status).toBe(401);
100
+ });
101
+
102
+ it('retries with the refreshed token on 401', async () => {
103
+ let callCount = 0;
104
+ mockFetch.mockImplementation((url: string) => {
105
+ if (typeof url === 'string' && url.includes('token/refresh')) {
106
+ return Promise.resolve({
107
+ ok: true,
108
+ status: 200,
109
+ json: async () => ({ access: REFRESHED_TOKEN }),
110
+ });
111
+ }
112
+ callCount++;
113
+ if (callCount === 1) {
114
+ // First call returns 401
115
+ return Promise.resolve({ ok: false, status: 401, json: async () => ({}) });
116
+ }
117
+ // Retry call returns 200
118
+ return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: 'ok' }) });
119
+ });
120
+
121
+ const response = await authFetch('/api/v1/resource/');
122
+ expect(response.status).toBe(200);
123
+ // Retry was called with the refreshed token
124
+ const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1];
125
+ const retryHeaders = lastCall[1]?.headers as Headers;
126
+ expect(retryHeaders.get('Authorization')).toBe(`Bearer ${REFRESHED_TOKEN}`);
127
+ });
128
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for getAccessToken / setAccessToken using sessionStorage.
3
+ * Regression for fund-your-startup-fe28 (access token was in module-level global).
4
+ */
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { getAccessToken, setAccessToken } from '../client/functions';
7
+
8
+ describe('Token storage (sessionStorage)', () => {
9
+ beforeEach(() => {
10
+ // Clear sessionStorage before each test
11
+ sessionStorage.clear();
12
+ // Reset to null so tests are independent
13
+ setAccessToken(null);
14
+ });
15
+
16
+ it('stores the token in sessionStorage, not a module-level global', () => {
17
+ setAccessToken('tok-abc123');
18
+
19
+ // Value is readable via the getter
20
+ expect(getAccessToken()).toBe('tok-abc123');
21
+
22
+ // Value is also in sessionStorage (not just a module variable)
23
+ expect(sessionStorage.getItem('auth_access_token')).toBe('tok-abc123');
24
+ });
25
+
26
+ it('returns null when no token has been stored', () => {
27
+ expect(getAccessToken()).toBeNull();
28
+ });
29
+
30
+ it('clearing the token removes it from sessionStorage', () => {
31
+ setAccessToken('some-token');
32
+ setAccessToken(null);
33
+
34
+ expect(getAccessToken()).toBeNull();
35
+ expect(sessionStorage.getItem('auth_access_token')).toBeNull();
36
+ });
37
+
38
+ it('overwrites an existing token', () => {
39
+ setAccessToken('first-token');
40
+ setAccessToken('second-token');
41
+
42
+ expect(getAccessToken()).toBe('second-token');
43
+ expect(sessionStorage.getItem('auth_access_token')).toBe('second-token');
44
+ });
45
+
46
+ it('reads fresh value from sessionStorage (survives module reload simulation)', () => {
47
+ // Simulate token set by a previous page load that wrote to sessionStorage
48
+ sessionStorage.setItem('auth_access_token', 'pre-existing-token');
49
+
50
+ // getAccessToken reads from sessionStorage, not just a cached module variable
51
+ expect(getAccessToken()).toBe('pre-existing-token');
52
+ });
53
+
54
+ it('isolated from other sessionStorage keys', () => {
55
+ sessionStorage.setItem('unrelated_key', 'should-not-matter');
56
+ setAccessToken('my-token');
57
+
58
+ expect(getAccessToken()).toBe('my-token');
59
+ expect(sessionStorage.getItem('unrelated_key')).toBe('should-not-matter');
60
+ });
61
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validatePassword, validateEmail } from '../validation';
3
+
4
+ describe('validatePassword', () => {
5
+ it('rejects passwords shorter than 8 chars', () => {
6
+ const result = validatePassword('Ab1');
7
+ expect(result.isValid).toBe(false);
8
+ expect(result.error).toMatch(/8 characters/);
9
+ });
10
+
11
+ it('rejects passwords with no uppercase letter', () => {
12
+ const result = validatePassword('abcdefg1');
13
+ expect(result.isValid).toBe(false);
14
+ expect(result.error).toMatch(/uppercase/i);
15
+ });
16
+
17
+ it('rejects passwords with no lowercase letter', () => {
18
+ const result = validatePassword('ABCDEFG1');
19
+ expect(result.isValid).toBe(false);
20
+ expect(result.error).toMatch(/lowercase/i);
21
+ });
22
+
23
+ it('rejects passwords with no number', () => {
24
+ const result = validatePassword('Abcdefgh');
25
+ expect(result.isValid).toBe(false);
26
+ expect(result.error).toMatch(/number/i);
27
+ });
28
+
29
+ it('accepts a password with uppercase, lowercase, and number — no special char required', () => {
30
+ const result = validatePassword('Testpassword1');
31
+ expect(result.isValid).toBe(true);
32
+ expect(result.error).toBeUndefined();
33
+ });
34
+
35
+ it('accepts a password that also has a special character', () => {
36
+ const result = validatePassword('Testpassword1!');
37
+ expect(result.isValid).toBe(true);
38
+ });
39
+
40
+ it('accepts the canonical test credential', () => {
41
+ // Ensures test account Testpassword1! passes both frontend and backend rules
42
+ expect(validatePassword('Testpassword1!').isValid).toBe(true);
43
+ // Without special char — must also pass (no drift from backend)
44
+ expect(validatePassword('Testpassword1').isValid).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('validateEmail', () => {
49
+ it('accepts valid email', () => {
50
+ expect(validateEmail('user@example.com')).toBe(true);
51
+ });
52
+
53
+ it('rejects email without @', () => {
54
+ expect(validateEmail('notanemail')).toBe(false);
55
+ });
56
+
57
+ it('rejects email without domain', () => {
58
+ expect(validateEmail('user@')).toBe(false);
59
+ });
60
+ });
@@ -158,7 +158,17 @@ export class AuthClient {
158
158
  throw new Error('Failed to fetch user data');
159
159
  }
160
160
 
161
- const user: AuthUser = await response.json();
161
+ const data = await response.json();
162
+ const raw = data.user || data;
163
+ const user: AuthUser = {
164
+ id: raw.id,
165
+ email: raw.email,
166
+ firstName: raw.first_name || raw.firstName || '',
167
+ lastName: raw.last_name || raw.lastName || '',
168
+ isEmailVerified: raw.is_email_verified ?? raw.isEmailVerified ?? false,
169
+ createdAt: raw.created_at || raw.createdAt || '',
170
+ updatedAt: raw.updated_at || raw.updatedAt || '',
171
+ };
162
172
 
163
173
  if (this.session) {
164
174
  this.session.user = user;
@@ -69,20 +69,66 @@ export function resolveAuthUrl(path: string): string {
69
69
  return `${AUTH_BASE_URL}${normalized}`;
70
70
  }
71
71
 
72
- // --- In-memory token store ---
72
+ // --- 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.
73
76
 
74
- let accessToken: string | null = null;
77
+ const TOKEN_STORAGE_KEY = 'auth_access_token';
78
+
79
+ let _memToken: string | null = null;
80
+
81
+ function _sessionStorageAvailable(): boolean {
82
+ try {
83
+ return typeof window !== 'undefined' && !!window.sessionStorage;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
75
88
 
76
89
  export function getAccessToken(): string | null {
77
- return accessToken;
90
+ if (_sessionStorageAvailable()) {
91
+ return sessionStorage.getItem(TOKEN_STORAGE_KEY);
92
+ }
93
+ return _memToken;
78
94
  }
79
95
 
80
96
  export function setAccessToken(token: string | null): void {
81
- accessToken = token;
97
+ if (_sessionStorageAvailable()) {
98
+ if (token === null) {
99
+ sessionStorage.removeItem(TOKEN_STORAGE_KEY);
100
+ } else {
101
+ sessionStorage.setItem(TOKEN_STORAGE_KEY, token);
102
+ }
103
+ return;
104
+ }
105
+ _memToken = token;
82
106
  }
83
107
 
84
108
  // --- Internal helpers ---
85
109
 
110
+ const AUTH_TIMEOUT_MS = 15_000;
111
+
112
+ /** Extract a human-readable message from a Django REST Framework error response body. */
113
+ function extractApiError(d: Record<string, unknown>, fallback: string): string {
114
+ // Standard DRF: { detail: "..." }
115
+ if (typeof d.detail === 'string') return d.detail
116
+ // Field-level: { email: ["already exists"] } or { non_field_errors: ["..."] }
117
+ for (const val of Object.values(d)) {
118
+ if (typeof val === 'string') return val
119
+ if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'string') return val[0]
120
+ }
121
+ return fallback
122
+ }
123
+
124
+ function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
125
+ const controller = new AbortController();
126
+ const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS);
127
+ return fetch(url, { ...options, signal: controller.signal }).finally(() =>
128
+ clearTimeout(timer)
129
+ );
130
+ }
131
+
86
132
  function normalizeUser(raw: unknown): AuthUser | null {
87
133
  if (!raw || typeof raw !== 'object') return null;
88
134
  const obj = raw as Record<string, unknown>;
@@ -121,7 +167,7 @@ function parseAuthResponse(data: unknown): { access?: string; user?: AuthUser }
121
167
  // --- Auth functions ---
122
168
 
123
169
  export async function signInWithCredentials(email: string, password: string) {
124
- const response = await fetch(resolveAuthUrl(AUTH_PATHS.TOKEN), {
170
+ const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN), {
125
171
  method: 'POST',
126
172
  headers: { 'Content-Type': 'application/json' },
127
173
  credentials: 'include',
@@ -159,7 +205,7 @@ export async function registerAccount(payload: {
159
205
  const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
160
206
  const lastFromName = rest.length ? rest.join(' ') : undefined;
161
207
 
162
- const response = await fetch(resolveAuthUrl(AUTH_PATHS.REGISTER), {
208
+ const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.REGISTER), {
163
209
  method: 'POST',
164
210
  headers: { 'Content-Type': 'application/json' },
165
211
  credentials: 'include',
@@ -175,9 +221,7 @@ export async function registerAccount(payload: {
175
221
  const data = await response.json().catch(() => ({}));
176
222
 
177
223
  if (!response.ok) {
178
- const d = data as Record<string, unknown>;
179
- const message = (d?.detail || d?.error || 'Registration failed') as string;
180
- throw new Error(message);
224
+ throw new Error(extractApiError(data as Record<string, unknown>, 'Registration failed'));
181
225
  }
182
226
 
183
227
  const parsed = parseAuthResponse(data);
@@ -209,7 +253,7 @@ export async function resetPassword(payload: {
209
253
  passwordConfirm: string;
210
254
  email?: string;
211
255
  }): Promise<void> {
212
- const response = await fetch(resolveAuthUrl(AUTH_PATHS.RESET_PASSWORD), {
256
+ const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.RESET_PASSWORD), {
213
257
  method: 'POST',
214
258
  headers: { 'Content-Type': 'application/json' },
215
259
  body: JSON.stringify({
@@ -229,7 +273,7 @@ export async function resetPassword(payload: {
229
273
  }
230
274
 
231
275
  export async function verifyEmail(token: string): Promise<void> {
232
- const response = await fetch(resolveAuthUrl(AUTH_PATHS.VERIFY_EMAIL), {
276
+ const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.VERIFY_EMAIL), {
233
277
  method: 'POST',
234
278
  headers: { 'Content-Type': 'application/json' },
235
279
  body: JSON.stringify({ token }),
@@ -282,7 +326,7 @@ export async function initiateGoogleOAuth(redirectUri: string): Promise<any> {
282
326
  }
283
327
 
284
328
  export async function completeGoogleOAuth(code: string, state: string) {
285
- const response = await fetch(
329
+ const response = await fetchWithTimeout(
286
330
  resolveAuthUrl(
287
331
  `${AUTH_PATHS.OAUTH_GOOGLE_CALLBACK}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
288
332
  ),
@@ -305,11 +349,24 @@ export async function completeGoogleOAuth(code: string, state: string) {
305
349
  return parsed;
306
350
  }
307
351
 
352
+ async function fetchCsrfToken(): Promise<void> {
353
+ if (getCsrfToken()) return;
354
+ try {
355
+ await fetch(resolveAuthUrl(`${API_BASE}/auth/csrf/`), {
356
+ credentials: 'include',
357
+ cache: 'no-store',
358
+ });
359
+ } catch (err) {
360
+ console.warn('[auth] CSRF token fetch failed — token refresh will fail:', err)
361
+ }
362
+ }
363
+
308
364
  export async function refreshAccessToken(): Promise<string | null> {
365
+ await fetchCsrfToken();
309
366
  const csrfToken = getCsrfToken();
310
367
  if (!csrfToken) return null;
311
368
 
312
- const response = await fetch(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
369
+ const response = await fetchWithTimeout(resolveAuthUrl(AUTH_PATHS.TOKEN_REFRESH), {
313
370
  method: 'POST',
314
371
  headers: {
315
372
  'Content-Type': 'application/json',
@@ -375,6 +432,18 @@ export async function signOut(): Promise<void> {
375
432
  }
376
433
  }
377
434
 
435
+ // Singleton refresh promise — ensures concurrent 401s share one refresh call
436
+ let _refreshPromise: Promise<string | null> | null = null;
437
+
438
+ function _refreshOnce(): Promise<string | null> {
439
+ if (!_refreshPromise) {
440
+ _refreshPromise = refreshAccessToken().finally(() => {
441
+ _refreshPromise = null;
442
+ });
443
+ }
444
+ return _refreshPromise;
445
+ }
446
+
378
447
  export async function authFetch(
379
448
  input: RequestInfo | URL,
380
449
  init: RequestInit = {}
@@ -395,7 +464,7 @@ export async function authFetch(
395
464
  });
396
465
 
397
466
  if (response.status === 401) {
398
- const refreshed = await refreshAccessToken();
467
+ const refreshed = await _refreshOnce();
399
468
  if (refreshed) {
400
469
  const retryHeaders = new Headers(init.headers || {});
401
470
  retryHeaders.set('Authorization', `Bearer ${refreshed}`);