@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.
- package/dist/chunk-CDNZRZ7Q.mjs +767 -0
- package/dist/chunk-CDNZRZ7Q.mjs.map +1 -0
- package/dist/chunk-S6J5FYQY.mjs +134 -0
- package/dist/chunk-S6J5FYQY.mjs.map +1 -0
- package/dist/chunk-TA46ASDJ.mjs +37 -0
- package/dist/chunk-TA46ASDJ.mjs.map +1 -0
- package/dist/client/index.d.mts +175 -0
- package/dist/client/index.d.ts +175 -0
- package/dist/client/index.js +858 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/index.mjs +5 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/index.d.mts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +971 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server/index.d.mts +83 -0
- package/dist/server/index.d.ts +83 -0
- package/dist/server/index.js +242 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/index.mjs +191 -0
- package/dist/server/index.mjs.map +1 -0
- package/dist/types/index.d.mts +209 -0
- package/dist/types/index.d.ts +209 -0
- package/dist/types/index.js +43 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +50 -18
- package/src/__tests__/auth-client.test.ts +125 -0
- package/src/__tests__/auth-fetch.test.ts +128 -0
- package/src/__tests__/token-storage.test.ts +61 -0
- package/src/__tests__/validation.test.ts +60 -0
- package/src/client/auth-client.ts +11 -1
- package/src/client/functions.ts +83 -14
- package/src/types/index.ts +100 -0
- package/src/utils/validation.ts +190 -0
|
@@ -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.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Shared authentication package for StartSimpli Next.js apps",
|
|
5
|
-
"main": "./
|
|
6
|
-
"types": "./
|
|
7
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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;
|
package/src/client/functions.ts
CHANGED
|
@@ -69,20 +69,66 @@ export function resolveAuthUrl(path: string): string {
|
|
|
69
69
|
return `${AUTH_BASE_URL}${normalized}`;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// ---
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
467
|
+
const refreshed = await _refreshOnce();
|
|
399
468
|
if (refreshed) {
|
|
400
469
|
const retryHeaders = new Headers(init.headers || {});
|
|
401
470
|
retryHeaders.set('Authorization', `Bearer ${refreshed}`);
|