@authms/core 0.1.0

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 ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@authms/core",
3
+ "version": "0.1.0",
4
+ "description": "AuthMS Core SDK — framework-agnostic token management, API client, and auth flows",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ },
14
+ "./platform": {
15
+ "import": "./dist/platform/index.mjs",
16
+ "require": "./dist/platform/index.js",
17
+ "types": "./dist/platform/index.d.ts"
18
+ }
19
+ },
20
+ "sideEffects": false,
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
23
+ "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
24
+ "typecheck": "tsc --noEmit",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "clean": "rimraf dist"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "keywords": [
34
+ "auth",
35
+ "authentication",
36
+ "oauth",
37
+ "oidc",
38
+ "authms"
39
+ ],
40
+ "license": "MIT",
41
+ "devDependencies": {
42
+ "@authms/tsconfig": "workspace:*",
43
+ "rimraf": "^5.0.0",
44
+ "tsup": "^8.4.0",
45
+ "typescript": "^5.8.0",
46
+ "vitest": "^3.0.0"
47
+ }
48
+ }
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { TokenManager } from '../token-manager';
3
+ import { ApiClient } from '../api-client';
4
+ import { AuthmsApiError, AuthmsAuthError, AuthmsNetworkError } from '../errors';
5
+
6
+ class MockStorage {
7
+ private store = new Map<string, string>();
8
+ getItem(k: string) { return this.store.get(k) ?? null; }
9
+ setItem(k: string, v: string) { this.store.set(k, v); }
10
+ removeItem(k: string) { this.store.delete(k); }
11
+ }
12
+
13
+ function createToken(expInSeconds = 900): string {
14
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
15
+ const payload = btoa(JSON.stringify({ sub: 'user-1', tenant_id: 'tenant-1', exp: Math.floor(Date.now() / 1000) + expInSeconds }));
16
+ return `${header}.${payload}.sig`;
17
+ }
18
+
19
+ const BASE_URL = 'https://api.example.com';
20
+
21
+ describe('ApiClient', () => {
22
+ // ── 1. GET request ──────────────────────────────────────────────
23
+ it('should auto-inject Authorization header and unwrap DataResponse envelope', async () => {
24
+ let capturedHeaders: Record<string, string> = {};
25
+ const mockHttp = {
26
+ request: async (_url: string, init?: RequestInit) => {
27
+ capturedHeaders = (init?.headers as Record<string, string>) || {};
28
+ return new Response(JSON.stringify({ code: 0, message: 'success', data: { id: '1', user_name: 'test' } }), { status: 200 });
29
+ },
30
+ };
31
+
32
+ const storage = new MockStorage();
33
+ const tokenManager = new TokenManager(storage);
34
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
35
+
36
+ const refreshFn = vi.fn();
37
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: refreshFn });
38
+
39
+ const result = await client.get('/api/v1/users/me');
40
+
41
+ expect(capturedHeaders['Authorization']).toBe(`Bearer ${tokenManager.getAccessToken()}`);
42
+ expect(result).toEqual({ id: '1', userName: 'test' });
43
+ expect(refreshFn).not.toHaveBeenCalled();
44
+ });
45
+
46
+ // ── 2. POST request ─────────────────────────────────────────────
47
+ it('should convert camelCase body to snake_case and send with Content-Type', async () => {
48
+ let capturedInit: RequestInit | undefined;
49
+ const mockHttp = {
50
+ request: async (_url: string, init?: RequestInit) => {
51
+ capturedInit = init;
52
+ return new Response(JSON.stringify({ code: 0, message: 'success', data: { id: '2', created_at: '2025-01-01' } }), { status: 200 });
53
+ },
54
+ };
55
+
56
+ const storage = new MockStorage();
57
+ const tokenManager = new TokenManager(storage);
58
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
59
+
60
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: vi.fn() });
61
+
62
+ const result = await client.post('/api/v1/users', { userName: 'alice', displayName: 'Alice' });
63
+
64
+ expect(capturedInit?.headers).toMatchObject({ 'Content-Type': 'application/json' });
65
+ const body = JSON.parse(capturedInit?.body as string);
66
+ expect(body).toEqual({ user_name: 'alice', display_name: 'Alice' });
67
+ expect(result).toEqual({ id: '2', createdAt: '2025-01-01' });
68
+ });
69
+
70
+ // ── 3. 401 triggers refresh + retry ─────────────────────────────
71
+ it('should trigger token refresh on 401 and retry the original request', async () => {
72
+ let callCount = 0;
73
+ const mockHttp = {
74
+ request: async (url: string, _init?: RequestInit) => {
75
+ callCount++;
76
+ if (url.includes('/api/v1/auth/refresh')) {
77
+ return new Response(JSON.stringify({ code: 0, data: { access_token: createToken(), refresh_token: 'new_rt', expires_in: 900 } }), { status: 200 });
78
+ }
79
+ if (callCount === 1) {
80
+ return new Response(JSON.stringify({ code: 401001, message: 'unauthorized' }), { status: 401 });
81
+ }
82
+ return new Response(JSON.stringify({ code: 0, message: 'success', data: { id: '3', user_name: 'bob' } }), { status: 200 });
83
+ },
84
+ };
85
+
86
+ const storage = new MockStorage();
87
+ const tokenManager = new TokenManager(storage);
88
+ tokenManager.setTokens(createToken(), 'rt-old', 900);
89
+
90
+ const refreshFn = vi.fn(async () => {
91
+ const resp = await mockHttp.request(`${BASE_URL}/api/v1/auth/refresh`, { method: 'POST' });
92
+ const json: Record<string, unknown> = await resp.json();
93
+ const d = json.data as Record<string, unknown>;
94
+ tokenManager.setTokens(d.access_token as string, d.refresh_token as string, d.expires_in as number);
95
+ });
96
+
97
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: refreshFn });
98
+
99
+ const result = await client.get('/api/v1/users/me');
100
+
101
+ expect(result).toEqual({ id: '3', userName: 'bob' });
102
+ expect(refreshFn).toHaveBeenCalledTimes(1);
103
+ expect(callCount).toBe(3);
104
+ });
105
+
106
+ // ── 4. 401 with no refresh token → force logout ─────────────────
107
+ it('should force logout when refresh fails (no refresh token)', async () => {
108
+ const mockHttp = {
109
+ request: async () => {
110
+ return new Response(JSON.stringify({ code: 401001, message: 'unauthorized' }), { status: 401 });
111
+ },
112
+ };
113
+
114
+ const storage = new MockStorage();
115
+ const tokenManager = new TokenManager(storage);
116
+ tokenManager.setTokens(createToken(), 'rt-expired', 900);
117
+
118
+ const refreshFn = vi.fn(async () => {
119
+ throw new Error('No refresh token available');
120
+ });
121
+ const onForceLogout = vi.fn();
122
+
123
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: refreshFn, onForceLogout });
124
+
125
+ await expect(client.get('/api/v1/users/me')).rejects.toMatchObject({
126
+ name: 'AuthmsAuthError',
127
+ code: 'SESSION_EXPIRED',
128
+ message: 'Session expired, please login again',
129
+ status: 401,
130
+ });
131
+
132
+ expect(refreshFn).toHaveBeenCalledTimes(1);
133
+ expect(onForceLogout).toHaveBeenCalledTimes(1);
134
+ expect(tokenManager.getAccessToken()).toBeNull();
135
+ expect(tokenManager.getRefreshToken()).toBeNull();
136
+ });
137
+
138
+ // ── 5. Single-flight: concurrent 401s → only one refresh ────────
139
+ it('should deduplicate concurrent 401s (single-flight refresh)', async () => {
140
+ let refreshCallCount = 0;
141
+ let httpCallCount = 0;
142
+ const mockHttp = {
143
+ request: async (url: string, _init?: RequestInit) => {
144
+ httpCallCount++;
145
+ if (url.includes('/api/v1/auth/refresh')) {
146
+ refreshCallCount++;
147
+ return new Response(JSON.stringify({ code: 0, data: { access_token: createToken(), refresh_token: 'new_rt', expires_in: 900 } }), { status: 200 });
148
+ }
149
+ if (httpCallCount <= 2) {
150
+ return new Response(JSON.stringify({ code: 401001, message: 'unauthorized' }), { status: 401 });
151
+ }
152
+ return new Response(JSON.stringify({ code: 0, message: 'success', data: { id: '1', user_name: 'test' } }), { status: 200 });
153
+ },
154
+ };
155
+
156
+ const storage = new MockStorage();
157
+ const tokenManager = new TokenManager(storage);
158
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
159
+
160
+ const refreshFn = vi.fn(async () => {
161
+ const resp = await mockHttp.request(`${BASE_URL}/api/v1/auth/refresh`, { method: 'POST' });
162
+ const json: Record<string, unknown> = await resp.json();
163
+ const d = json.data as Record<string, unknown>;
164
+ tokenManager.setTokens(d.access_token as string, d.refresh_token as string, d.expires_in as number);
165
+ });
166
+
167
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: refreshFn });
168
+
169
+ const [r1, r2] = await Promise.all([
170
+ client.get('/api/v1/resource-1'),
171
+ client.get('/api/v1/resource-2'),
172
+ ]);
173
+
174
+ expect(r1).toEqual({ id: '1', userName: 'test' });
175
+ expect(r2).toEqual({ id: '1', userName: 'test' });
176
+ expect(refreshCallCount).toBe(1);
177
+ expect(refreshFn).toHaveBeenCalledTimes(1);
178
+ });
179
+
180
+ // ── 6. snake_case response keys → camelCase ─────────────────────
181
+ it('should convert snake_case response keys to camelCase', async () => {
182
+ const mockHttp = {
183
+ request: async () => {
184
+ return new Response(JSON.stringify({
185
+ code: 0,
186
+ message: 'success',
187
+ data: {
188
+ user_id: 'u-1',
189
+ first_name: 'John',
190
+ last_name: 'Doe',
191
+ created_at: '2025-01-01T00:00:00Z',
192
+ nested_obj: { inner_key: 'val' },
193
+ },
194
+ }), { status: 200 });
195
+ },
196
+ };
197
+
198
+ const storage = new MockStorage();
199
+ const tokenManager = new TokenManager(storage);
200
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
201
+
202
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: vi.fn() });
203
+
204
+ const result = await client.get('/api/v1/profile');
205
+
206
+ expect(result).toEqual({
207
+ userId: 'u-1',
208
+ firstName: 'John',
209
+ lastName: 'Doe',
210
+ createdAt: '2025-01-01T00:00:00Z',
211
+ nestedObj: { innerKey: 'val' },
212
+ });
213
+ });
214
+
215
+ // ── 7. List response unwrapping ──────────────────────────────────
216
+ it('should unwrap list response (items + total + pagination) correctly', async () => {
217
+ const mockHttp = {
218
+ request: async () => {
219
+ return new Response(JSON.stringify({
220
+ code: 0,
221
+ message: 'success',
222
+ items: [
223
+ { user_id: 'u-1', user_name: 'alice' },
224
+ { user_id: 'u-2', user_name: 'bob' },
225
+ ],
226
+ total: 42,
227
+ pagination: { page: 1, page_size: 20, total_pages: 3 },
228
+ }), { status: 200 });
229
+ },
230
+ };
231
+
232
+ const storage = new MockStorage();
233
+ const tokenManager = new TokenManager(storage);
234
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
235
+
236
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: vi.fn() });
237
+
238
+ const result = await client.get('/api/v1/users');
239
+
240
+ expect(result).toEqual({
241
+ items: [
242
+ { userId: 'u-1', userName: 'alice' },
243
+ { userId: 'u-2', userName: 'bob' },
244
+ ],
245
+ total: 42,
246
+ pagination: { page: 1, pageSize: 20, totalPages: 3 },
247
+ });
248
+ });
249
+
250
+ // ── 8. 401 on refresh endpoint → no refresh loop ─────────────────
251
+ it('should NOT trigger another refresh on 401 from refresh endpoint', async () => {
252
+ let callCount = 0;
253
+ const mockHttp = {
254
+ request: async () => {
255
+ callCount++;
256
+ return new Response(JSON.stringify({ code: 401002, message: 'refresh token expired' }), { status: 401 });
257
+ },
258
+ };
259
+
260
+ const storage = new MockStorage();
261
+ const tokenManager = new TokenManager(storage);
262
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
263
+
264
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: vi.fn() });
265
+
266
+ await expect(client.get('/api/v1/auth/refresh')).rejects.toMatchObject({
267
+ name: 'AuthmsApiError',
268
+ code: '401002',
269
+ message: 'refresh token expired',
270
+ status: 401,
271
+ });
272
+
273
+ expect(callCount).toBe(1);
274
+ });
275
+
276
+ // ── 9. 404 tenant not found → error handling ────────────────────
277
+ it('should handle 404 tenant not found gracefully', async () => {
278
+ const mockHttp = {
279
+ request: async () => {
280
+ return new Response(JSON.stringify({ code: 404001, message: 'tenant not found' }), { status: 404 });
281
+ },
282
+ };
283
+
284
+ const storage = new MockStorage();
285
+ const tokenManager = new TokenManager(storage);
286
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
287
+
288
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: vi.fn() });
289
+
290
+ await expect(client.get('/api/v1/tenants/unknown')).rejects.toMatchObject({
291
+ name: 'AuthmsApiError',
292
+ code: '404001',
293
+ message: 'tenant not found',
294
+ status: 404,
295
+ });
296
+ });
297
+
298
+ // ── 10. Network error → AuthmsNetworkError ───────────────────────
299
+ it('should throw AuthmsNetworkError on network failure', async () => {
300
+ const mockHttp = {
301
+ request: async () => {
302
+ throw new TypeError('Failed to fetch');
303
+ },
304
+ };
305
+
306
+ const storage = new MockStorage();
307
+ const tokenManager = new TokenManager(storage);
308
+ tokenManager.setTokens(createToken(), 'rt-1', 900);
309
+
310
+ const client = new ApiClient({ baseUrl: BASE_URL, tokenManager, http: mockHttp, refreshTokenFn: vi.fn() });
311
+
312
+ await expect(client.get('/api/v1/users/me')).rejects.toBeInstanceOf(AuthmsNetworkError);
313
+ });
314
+ });