@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/dist/index.d.mts +398 -0
- package/dist/index.d.ts +398 -0
- package/dist/index.js +1337 -0
- package/dist/index.mjs +1294 -0
- package/package.json +48 -0
- package/src/__tests__/api-client.test.ts +314 -0
- package/src/__tests__/auth-client.test.ts +412 -0
- package/src/__tests__/discovery.test.ts +129 -0
- package/src/__tests__/password-transmission.test.ts +131 -0
- package/src/__tests__/sync.test.ts +85 -0
- package/src/__tests__/token-manager.test.ts +104 -0
- package/src/api-client.ts +203 -0
- package/src/auth-client.ts +368 -0
- package/src/authms.ts +244 -0
- package/src/binding.ts +126 -0
- package/src/crypto/index.ts +6 -0
- package/src/crypto/password-transmission.ts +198 -0
- package/src/crypto/pow-solver.ts +41 -0
- package/src/discovery.ts +77 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +39 -0
- package/src/platform/browser.ts +23 -0
- package/src/platform/index.ts +3 -0
- package/src/platform/memory.ts +19 -0
- package/src/platform/types.ts +21 -0
- package/src/plugin.ts +8 -0
- package/src/sync.ts +51 -0
- package/src/token-manager.ts +140 -0
- package/src/types.ts +113 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { TokenManager } from '../token-manager';
|
|
3
|
+
import { AuthClient } from '../auth-client';
|
|
4
|
+
import { AuthmsAuthError } from '../errors';
|
|
5
|
+
import type { StorageAdapter } from '../platform/types';
|
|
6
|
+
|
|
7
|
+
class MockStorage implements StorageAdapter {
|
|
8
|
+
private store = new Map<string, string>();
|
|
9
|
+
getItem(k: string) { return this.store.get(k) ?? null; }
|
|
10
|
+
setItem(k: string, v: string) { this.store.set(k, v); }
|
|
11
|
+
removeItem(k: string) { this.store.delete(k); }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createMockHttp(responses: Record<string, unknown>) {
|
|
15
|
+
let lastRequest: { url: string; method: string; body: string; headers?: Record<string, string> } | null = null;
|
|
16
|
+
return {
|
|
17
|
+
request: async (url: string, init?: RequestInit) => {
|
|
18
|
+
const method = init?.method || 'GET';
|
|
19
|
+
lastRequest = { url, method, body: (init?.body as string) || '', headers: (init?.headers as Record<string, string>) || {} };
|
|
20
|
+
const key = `${method} ${url}`;
|
|
21
|
+
const res = responses[key];
|
|
22
|
+
if (!res) {
|
|
23
|
+
return new Response(JSON.stringify({ code: 404, message: `Unexpected: ${key}` }), { status: 404 });
|
|
24
|
+
}
|
|
25
|
+
const status = (res as any).__status || 200;
|
|
26
|
+
return new Response(JSON.stringify(res), { status, headers: { 'Content-Type': 'application/json' } });
|
|
27
|
+
},
|
|
28
|
+
getLastRequest: () => lastRequest,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createToken(expInSeconds = 900): string {
|
|
33
|
+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
34
|
+
const payload = btoa(JSON.stringify({ sub: 'user-1', user_id: 'user-1', tenant_id: 'tenant-1', exp: Math.floor(Date.now() / 1000) + expInSeconds }));
|
|
35
|
+
return `${header}.${payload}.sig`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BASE_URL = 'https://api.example.com';
|
|
39
|
+
|
|
40
|
+
const AUTH_CONFIG_KEY = `GET ${BASE_URL}/identity/api/v1/public/auth-config`;
|
|
41
|
+
const AUTH_CONFIG_TENANT_KEY = `GET ${BASE_URL}/identity/api/v1/public/auth-config/t1`;
|
|
42
|
+
const LOGIN_KEY = `POST ${BASE_URL}/identity/api/v1/auth/login`;
|
|
43
|
+
const REGISTER_KEY = `POST ${BASE_URL}/identity/api/v1/auth/register`;
|
|
44
|
+
const LOGOUT_KEY = `POST ${BASE_URL}/identity/api/v1/auth/logout`;
|
|
45
|
+
const REFRESH_KEY = `POST ${BASE_URL}/identity/api/v1/auth/refresh`;
|
|
46
|
+
|
|
47
|
+
function plainAuthConfig() {
|
|
48
|
+
return {
|
|
49
|
+
data: {
|
|
50
|
+
tenant_id: 't1',
|
|
51
|
+
login_methods: ['password'],
|
|
52
|
+
password_policy: { password_transmission: 'plain', min_length: 8 },
|
|
53
|
+
captcha_enabled: false,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hashAuthConfig() {
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
tenant_id: 't1',
|
|
62
|
+
login_methods: ['password'],
|
|
63
|
+
password_policy: { password_transmission: 'hash', min_length: 8 },
|
|
64
|
+
captcha_enabled: false,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const LOGIN_SUCCESS_RESPONSE = {
|
|
70
|
+
data: { access_token: 'at', refresh_token: 'rt', expires_in: 900, user: { id: 'user-1' } },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const REGISTER_SUCCESS_RESPONSE = {
|
|
74
|
+
data: { access_token: 'at', refresh_token: 'rt', user: { id: 'user-1' } },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const REFRESH_TOKEN_REUSE_RESPONSE = {
|
|
78
|
+
code: '40000201',
|
|
79
|
+
message: 'Token reused',
|
|
80
|
+
__status: 401,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
describe('AuthClient', () => {
|
|
84
|
+
let storage: MockStorage;
|
|
85
|
+
let tokenManager: TokenManager;
|
|
86
|
+
let mockHttp: ReturnType<typeof createMockHttp>;
|
|
87
|
+
let client: AuthClient;
|
|
88
|
+
|
|
89
|
+
function setupClient(responses: Record<string, unknown>) {
|
|
90
|
+
mockHttp = createMockHttp(responses);
|
|
91
|
+
storage = new MockStorage();
|
|
92
|
+
tokenManager = new TokenManager(storage);
|
|
93
|
+
client = new AuthClient({
|
|
94
|
+
tokenManager,
|
|
95
|
+
http: mockHttp,
|
|
96
|
+
baseUrl: BASE_URL,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseBody(body: string): Record<string, unknown> {
|
|
101
|
+
return JSON.parse(body) as Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('login', () => {
|
|
105
|
+
it('sends password as-is in plain mode', async () => {
|
|
106
|
+
setupClient({
|
|
107
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
108
|
+
[LOGIN_KEY]: LOGIN_SUCCESS_RESPONSE,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const result = await client.login({ email: 'test@example.com', password: 'testpass' });
|
|
112
|
+
|
|
113
|
+
expect(result.accessToken).toBe('at');
|
|
114
|
+
expect(result.refreshToken).toBe('rt');
|
|
115
|
+
expect(result.expiresIn).toBe(900);
|
|
116
|
+
expect(result.user.id).toBe('user-1');
|
|
117
|
+
|
|
118
|
+
const lastReq = mockHttp.getLastRequest()!;
|
|
119
|
+
const body = parseBody(lastReq.body);
|
|
120
|
+
expect(body.password).toBe('testpass');
|
|
121
|
+
expect(body.password_transmission).toBe('plain');
|
|
122
|
+
expect(body.identity).toBe('test@example.com');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('sends SHA-256 hashed password in hash mode', async () => {
|
|
126
|
+
setupClient({
|
|
127
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
128
|
+
[AUTH_CONFIG_TENANT_KEY]: hashAuthConfig(),
|
|
129
|
+
[LOGIN_KEY]: LOGIN_SUCCESS_RESPONSE,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await client.login({ email: 'test@example.com', password: 'testpass', tenantId: 't1' });
|
|
133
|
+
|
|
134
|
+
const lastReq = mockHttp.getLastRequest()!;
|
|
135
|
+
const body = parseBody(lastReq.body);
|
|
136
|
+
expect(body.password).not.toBe('testpass');
|
|
137
|
+
expect(body.password).toMatch(/^[0-9a-f]{64}$/);
|
|
138
|
+
expect(body.password_transmission).toBe('hash');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('throws AuthmsAuthError on 401', async () => {
|
|
142
|
+
setupClient({
|
|
143
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
144
|
+
[LOGIN_KEY]: { code: '40100001', message: 'Invalid credentials', __status: 401 },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await expect(
|
|
148
|
+
client.login({ email: 'bad@example.com', password: 'wrong' }),
|
|
149
|
+
).rejects.toThrow(AuthmsAuthError);
|
|
150
|
+
|
|
151
|
+
await expect(
|
|
152
|
+
client.login({ email: 'bad@example.com', password: 'wrong' }),
|
|
153
|
+
).rejects.toMatchObject({
|
|
154
|
+
name: 'AuthmsAuthError',
|
|
155
|
+
code: '40100001',
|
|
156
|
+
status: 401,
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('register', () => {
|
|
162
|
+
it('registers with password preprocessing', async () => {
|
|
163
|
+
setupClient({
|
|
164
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
165
|
+
[REGISTER_KEY]: REGISTER_SUCCESS_RESPONSE,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const result = await client.register({
|
|
169
|
+
email: 'new@example.com',
|
|
170
|
+
password: 'newpass',
|
|
171
|
+
username: 'newuser',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.accessToken).toBe('at');
|
|
175
|
+
expect(result.refreshToken).toBe('rt');
|
|
176
|
+
expect(result.user.id).toBe('user-1');
|
|
177
|
+
|
|
178
|
+
const lastReq = mockHttp.getLastRequest()!;
|
|
179
|
+
const body = parseBody(lastReq.body);
|
|
180
|
+
expect(body.email).toBe('new@example.com');
|
|
181
|
+
expect(body.password).toBe('newpass');
|
|
182
|
+
expect(body.password_transmission).toBe('plain');
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('logout', () => {
|
|
187
|
+
it('clears tokens', async () => {
|
|
188
|
+
setupClient({
|
|
189
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
190
|
+
[LOGIN_KEY]: LOGIN_SUCCESS_RESPONSE,
|
|
191
|
+
[LOGOUT_KEY]: { data: {} },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await client.login({ email: 'test@example.com', password: 'testpass' });
|
|
195
|
+
expect(tokenManager.getRefreshToken()).toBe('rt');
|
|
196
|
+
|
|
197
|
+
await client.logout();
|
|
198
|
+
expect(tokenManager.getRefreshToken()).toBeNull();
|
|
199
|
+
expect(tokenManager.getAccessToken()).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('sends Authorization header on logout', async () => {
|
|
203
|
+
const validToken = createToken(900);
|
|
204
|
+
tokenManager.setTokens(validToken, 'rt', 900);
|
|
205
|
+
const capturedHeaders: Record<string, string> = {};
|
|
206
|
+
|
|
207
|
+
const http = {
|
|
208
|
+
request: async (url: string, init?: RequestInit) => {
|
|
209
|
+
Object.assign(capturedHeaders, init?.headers || {});
|
|
210
|
+
return new Response(JSON.stringify({ data: {} }), { status: 200 });
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const testClient = new AuthClient({ tokenManager, http, baseUrl: BASE_URL });
|
|
215
|
+
await testClient.logout();
|
|
216
|
+
expect(capturedHeaders['Authorization']).toBe(`Bearer ${validToken}`);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('refreshToken', () => {
|
|
221
|
+
it('refreshes tokens on success', async () => {
|
|
222
|
+
const newAt = createToken(900);
|
|
223
|
+
setupClient({
|
|
224
|
+
[REFRESH_KEY]: {
|
|
225
|
+
data: { access_token: newAt, refresh_token: 'new_rt', expires_in: 900 },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
tokenManager.setTokens(createToken(), 'old_rt', 900);
|
|
230
|
+
|
|
231
|
+
await client.refreshToken();
|
|
232
|
+
|
|
233
|
+
expect(tokenManager.getRefreshToken()).toBe('new_rt');
|
|
234
|
+
expect(tokenManager.getAccessToken()).toBe(newAt);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('throws TOKEN_REUSE on 400002xx code', async () => {
|
|
238
|
+
setupClient({
|
|
239
|
+
[REFRESH_KEY]: REFRESH_TOKEN_REUSE_RESPONSE,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
tokenManager.setTokens(createToken(), 'old_rt', 900);
|
|
243
|
+
|
|
244
|
+
await expect(client.refreshToken()).rejects.toMatchObject({
|
|
245
|
+
name: 'AuthmsAuthError',
|
|
246
|
+
code: 'TOKEN_REUSE',
|
|
247
|
+
status: 401,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(tokenManager.getRefreshToken()).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('fetchAuthConfig', () => {
|
|
255
|
+
it('fetches and returns auth config', async () => {
|
|
256
|
+
setupClient({
|
|
257
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const config = await client.fetchAuthConfig();
|
|
261
|
+
|
|
262
|
+
expect(config.tenantId).toBe('t1');
|
|
263
|
+
expect(config.loginMethods).toEqual(['password']);
|
|
264
|
+
expect(config.passwordPolicy.mode).toBe('plain');
|
|
265
|
+
expect(config.passwordPolicy.minLength).toBe(8);
|
|
266
|
+
expect(config.captchaEnabled).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('second call returns cached data', async () => {
|
|
270
|
+
let callCount = 0;
|
|
271
|
+
const trackingHttp = {
|
|
272
|
+
request: async (url: string, init?: RequestInit) => {
|
|
273
|
+
callCount++;
|
|
274
|
+
const key = (init?.method || 'GET') + ' ' + url;
|
|
275
|
+
const res = ({ [AUTH_CONFIG_KEY]: plainAuthConfig() } as Record<string, unknown>)[key];
|
|
276
|
+
return new Response(JSON.stringify(res), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
storage = new MockStorage();
|
|
281
|
+
tokenManager = new TokenManager(storage);
|
|
282
|
+
client = new AuthClient({ tokenManager, http: trackingHttp, baseUrl: BASE_URL });
|
|
283
|
+
|
|
284
|
+
await client.fetchAuthConfig();
|
|
285
|
+
expect(callCount).toBe(1);
|
|
286
|
+
|
|
287
|
+
await client.fetchAuthConfig();
|
|
288
|
+
expect(callCount).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('clearConfigCache invalidates cache', async () => {
|
|
292
|
+
let callCount = 0;
|
|
293
|
+
const trackingHttp = {
|
|
294
|
+
request: async (url: string, init?: RequestInit) => {
|
|
295
|
+
callCount++;
|
|
296
|
+
const key = (init?.method || 'GET') + ' ' + url;
|
|
297
|
+
const res = ({ [AUTH_CONFIG_KEY]: plainAuthConfig() } as Record<string, unknown>)[key];
|
|
298
|
+
return new Response(JSON.stringify(res), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
storage = new MockStorage();
|
|
303
|
+
tokenManager = new TokenManager(storage);
|
|
304
|
+
client = new AuthClient({ tokenManager, http: trackingHttp, baseUrl: BASE_URL });
|
|
305
|
+
|
|
306
|
+
await client.fetchAuthConfig();
|
|
307
|
+
expect(callCount).toBe(1);
|
|
308
|
+
|
|
309
|
+
await client.fetchAuthConfig();
|
|
310
|
+
expect(callCount).toBe(1);
|
|
311
|
+
|
|
312
|
+
client.clearConfigCache();
|
|
313
|
+
|
|
314
|
+
await client.fetchAuthConfig();
|
|
315
|
+
expect(callCount).toBe(2);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('changePassword', () => {
|
|
320
|
+
const CHANGE_PASSWORD_KEY = `PUT ${BASE_URL}/identity/api/v1/auth/me/password`;
|
|
321
|
+
|
|
322
|
+
it('sends processed password with transmission', async () => {
|
|
323
|
+
setupClient({
|
|
324
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
325
|
+
[CHANGE_PASSWORD_KEY]: { data: {} },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await client.changePassword('oldpass', 'newpass123');
|
|
329
|
+
|
|
330
|
+
const lastReq = mockHttp.getLastRequest()!;
|
|
331
|
+
const body = parseBody(lastReq.body);
|
|
332
|
+
expect(body.current_password).toBe('oldpass');
|
|
333
|
+
expect(body.password_transmission).toBe('plain');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('throws AuthmsAuthError on 4xx', async () => {
|
|
337
|
+
setupClient({
|
|
338
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
339
|
+
[CHANGE_PASSWORD_KEY]: { code: '40000001', message: 'Invalid current password', __status: 400 },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await expect(
|
|
343
|
+
client.changePassword('wrongold', 'newpass123'),
|
|
344
|
+
).rejects.toThrow(AuthmsAuthError);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('loginWithOAuth', () => {
|
|
349
|
+
it('throws NOT_BROWSER when window is undefined', async () => {
|
|
350
|
+
setupClient({});
|
|
351
|
+
|
|
352
|
+
await expect(
|
|
353
|
+
client.loginWithOAuth({ provider: 'google' }),
|
|
354
|
+
).rejects.toThrow(AuthmsAuthError);
|
|
355
|
+
|
|
356
|
+
await expect(
|
|
357
|
+
client.loginWithOAuth({ provider: 'google' }),
|
|
358
|
+
).rejects.toMatchObject({
|
|
359
|
+
name: 'AuthmsAuthError',
|
|
360
|
+
code: 'NOT_BROWSER',
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('login with captcha', () => {
|
|
366
|
+
it('includes captcha fields in body', async () => {
|
|
367
|
+
setupClient({
|
|
368
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
369
|
+
[LOGIN_KEY]: LOGIN_SUCCESS_RESPONSE,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
await client.login({
|
|
373
|
+
email: 'test@example.com',
|
|
374
|
+
password: 'testpass',
|
|
375
|
+
captchaToken: 'captcha-abc',
|
|
376
|
+
captchaProvider: 'turnstile',
|
|
377
|
+
captchaChallengeId: 'challenge-123',
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const lastReq = mockHttp.getLastRequest()!;
|
|
381
|
+
const body = parseBody(lastReq.body);
|
|
382
|
+
expect(body.captcha_token).toBe('captcha-abc');
|
|
383
|
+
expect(body.captcha_provider).toBe('turnstile');
|
|
384
|
+
expect(body.captcha_challenge_id).toBe('challenge-123');
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('getProfile', () => {
|
|
389
|
+
const PROFILE_KEY = `GET ${BASE_URL}/identity/api/v1/auth/me`;
|
|
390
|
+
|
|
391
|
+
it('fetches and returns user data, caches in tokenManager', async () => {
|
|
392
|
+
setupClient({
|
|
393
|
+
[AUTH_CONFIG_KEY]: plainAuthConfig(),
|
|
394
|
+
[LOGIN_KEY]: LOGIN_SUCCESS_RESPONSE,
|
|
395
|
+
[PROFILE_KEY]: {
|
|
396
|
+
data: { id: 'user-1', email: 'test@example.com', username: 'testuser' },
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
await client.login({ email: 'test@example.com', password: 'testpass' });
|
|
401
|
+
|
|
402
|
+
const profile = await client.getProfile();
|
|
403
|
+
|
|
404
|
+
expect(profile).not.toBeNull();
|
|
405
|
+
expect(profile!.id).toBe('user-1');
|
|
406
|
+
expect(profile!.email).toBe('test@example.com');
|
|
407
|
+
expect(profile!.username).toBe('testuser');
|
|
408
|
+
expect(tokenManager.getUser()).not.toBeNull();
|
|
409
|
+
expect(tokenManager.getUser()!.email).toBe('test@example.com');
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Discovery } from '../discovery';
|
|
3
|
+
|
|
4
|
+
const VALID_METADATA = {
|
|
5
|
+
issuer: 'https://auth.example.com',
|
|
6
|
+
authorization_endpoint: 'https://auth.example.com/oauth/authorize',
|
|
7
|
+
token_endpoint: 'https://auth.example.com/oauth/token',
|
|
8
|
+
userinfo_endpoint: 'https://auth.example.com/oauth/userinfo',
|
|
9
|
+
jwks_uri: 'https://auth.example.com/oauth/jwks',
|
|
10
|
+
end_session_endpoint: 'https://auth.example.com/oauth/logout',
|
|
11
|
+
scopes_supported: ['openid', 'profile', 'email'],
|
|
12
|
+
response_types_supported: ['code'],
|
|
13
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
class MockHttp {
|
|
17
|
+
calls = 0;
|
|
18
|
+
responses: Map<string, any> = new Map();
|
|
19
|
+
|
|
20
|
+
request = async (url: string) => {
|
|
21
|
+
this.calls++;
|
|
22
|
+
const res = this.responses.get(url);
|
|
23
|
+
if (!res) return new Response('{}', { status: 404 });
|
|
24
|
+
return new Response(JSON.stringify(res), { status: res.__status || 200 });
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ISSUER = 'https://auth.example.com';
|
|
29
|
+
const WELL_KNOWN_URL = `${ISSUER}/.well-known/openid-configuration`;
|
|
30
|
+
|
|
31
|
+
describe('Discovery', () => {
|
|
32
|
+
it('should discover with valid OIDC metadata', async () => {
|
|
33
|
+
const mockHttp = new MockHttp();
|
|
34
|
+
mockHttp.responses.set(WELL_KNOWN_URL, VALID_METADATA);
|
|
35
|
+
|
|
36
|
+
const discovery = new Discovery(mockHttp);
|
|
37
|
+
const metadata = await discovery.discover(ISSUER);
|
|
38
|
+
|
|
39
|
+
expect(metadata.issuer).toBe(VALID_METADATA.issuer);
|
|
40
|
+
expect(metadata.authorization_endpoint).toBe(VALID_METADATA.authorization_endpoint);
|
|
41
|
+
expect(metadata.token_endpoint).toBe(VALID_METADATA.token_endpoint);
|
|
42
|
+
expect(metadata.jwks_uri).toBe(VALID_METADATA.jwks_uri);
|
|
43
|
+
expect(metadata.end_session_endpoint).toBe(VALID_METADATA.end_session_endpoint);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should unwrap DataResponse-wrapped metadata', async () => {
|
|
47
|
+
const mockHttp = new MockHttp();
|
|
48
|
+
mockHttp.responses.set(WELL_KNOWN_URL, {
|
|
49
|
+
code: 0,
|
|
50
|
+
message: 'success',
|
|
51
|
+
data: VALID_METADATA,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const discovery = new Discovery(mockHttp);
|
|
55
|
+
const metadata = await discovery.discover(ISSUER);
|
|
56
|
+
|
|
57
|
+
expect(metadata.issuer).toBe(VALID_METADATA.issuer);
|
|
58
|
+
expect(metadata.token_endpoint).toBe(VALID_METADATA.token_endpoint);
|
|
59
|
+
expect(metadata.jwks_uri).toBe(VALID_METADATA.jwks_uri);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should throw on HTTP error', async () => {
|
|
63
|
+
const mockHttp = new MockHttp();
|
|
64
|
+
mockHttp.responses.set(WELL_KNOWN_URL, { __status: 500 });
|
|
65
|
+
|
|
66
|
+
const discovery = new Discovery(mockHttp);
|
|
67
|
+
await expect(discovery.discover(ISSUER)).rejects.toThrow(
|
|
68
|
+
'OIDC Discovery failed: HTTP 500',
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should throw when metadata is missing required fields', async () => {
|
|
73
|
+
const mockHttp = new MockHttp();
|
|
74
|
+
mockHttp.responses.set(WELL_KNOWN_URL, { issuer: 'https://foo' });
|
|
75
|
+
|
|
76
|
+
const discovery = new Discovery(mockHttp);
|
|
77
|
+
await expect(discovery.discover(ISSUER)).rejects.toThrow(
|
|
78
|
+
'OIDC Discovery response missing required fields',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should cache metadata — second discover() returns cached without HTTP request', async () => {
|
|
83
|
+
const mockHttp = new MockHttp();
|
|
84
|
+
mockHttp.responses.set(WELL_KNOWN_URL, VALID_METADATA);
|
|
85
|
+
|
|
86
|
+
const discovery = new Discovery(mockHttp);
|
|
87
|
+
|
|
88
|
+
const first = await discovery.discover(ISSUER);
|
|
89
|
+
expect(first.issuer).toBe(VALID_METADATA.issuer);
|
|
90
|
+
expect(mockHttp.calls).toBe(1);
|
|
91
|
+
|
|
92
|
+
const second = await discovery.discover(ISSUER);
|
|
93
|
+
expect(second.issuer).toBe(VALID_METADATA.issuer);
|
|
94
|
+
expect(mockHttp.calls).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return the end_session_endpoint from discovered metadata', async () => {
|
|
98
|
+
const mockHttp = new MockHttp();
|
|
99
|
+
mockHttp.responses.set(WELL_KNOWN_URL, VALID_METADATA);
|
|
100
|
+
|
|
101
|
+
const discovery = new Discovery(mockHttp);
|
|
102
|
+
await discovery.discover(ISSUER);
|
|
103
|
+
|
|
104
|
+
expect(discovery.getEndSessionEndpoint()).toBe('https://auth.example.com/oauth/logout');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should return the jwks_uri from discovered metadata', async () => {
|
|
108
|
+
const mockHttp = new MockHttp();
|
|
109
|
+
mockHttp.responses.set(WELL_KNOWN_URL, VALID_METADATA);
|
|
110
|
+
|
|
111
|
+
const discovery = new Discovery(mockHttp);
|
|
112
|
+
await discovery.discover(ISSUER);
|
|
113
|
+
|
|
114
|
+
expect(discovery.getJWKSUri()).toBe('https://auth.example.com/oauth/jwks');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should call discover only once when cached', async () => {
|
|
118
|
+
const mockHttp = new MockHttp();
|
|
119
|
+
mockHttp.responses.set(WELL_KNOWN_URL, VALID_METADATA);
|
|
120
|
+
|
|
121
|
+
const discovery = new Discovery(mockHttp);
|
|
122
|
+
|
|
123
|
+
await discovery.discover(ISSUER);
|
|
124
|
+
await discovery.discover(ISSUER);
|
|
125
|
+
await discovery.discover(ISSUER);
|
|
126
|
+
|
|
127
|
+
expect(mockHttp.calls).toBe(1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { processPasswordForTransmission } from '../crypto/password-transmission';
|
|
3
|
+
import type { KeyExchangeFn } from '../crypto/password-transmission';
|
|
4
|
+
import type { PasswordPolicyConfig } from '../types';
|
|
5
|
+
|
|
6
|
+
const mockKeyExchange: KeyExchangeFn = async () => {
|
|
7
|
+
const kp = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
8
|
+
const pubRaw = await crypto.subtle.exportKey('raw', kp.publicKey);
|
|
9
|
+
return { serverPubKey: btoa(String.fromCharCode(...new Uint8Array(pubRaw))), keyExchangeId: 'ke_123' };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function generateRsaPublicKeyPEM(): Promise<string> {
|
|
13
|
+
const kp = await crypto.subtle.generateKey(
|
|
14
|
+
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
|
15
|
+
true,
|
|
16
|
+
['encrypt', 'decrypt'],
|
|
17
|
+
);
|
|
18
|
+
const spki = await crypto.subtle.exportKey('spki', kp.publicKey);
|
|
19
|
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
|
20
|
+
const lines = b64.match(/.{1,64}/g) ?? [];
|
|
21
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createPolicy(overrides: Partial<PasswordPolicyConfig> = {}): PasswordPolicyConfig {
|
|
25
|
+
return {
|
|
26
|
+
mode: 'plain',
|
|
27
|
+
minLength: 8,
|
|
28
|
+
requireUpper: true,
|
|
29
|
+
tenantId: 'tenant-1',
|
|
30
|
+
publicKey: '',
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('processPasswordForTransmission', () => {
|
|
36
|
+
let rawPassword: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
rawPassword = 'MySecurePass123!';
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('plain mode - returns password unchanged with transmission plain', async () => {
|
|
43
|
+
const policy = createPolicy({ mode: 'plain' });
|
|
44
|
+
const result = await processPasswordForTransmission(rawPassword, policy);
|
|
45
|
+
|
|
46
|
+
expect(result.password).toBe(rawPassword);
|
|
47
|
+
expect(result.passwordTransmission).toBe('plain');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('hash mode - returns SHA-256(password|tenantId) in hex with transmission hash', async () => {
|
|
51
|
+
const policy = createPolicy({ mode: 'hash' });
|
|
52
|
+
const result = await processPasswordForTransmission(rawPassword, policy);
|
|
53
|
+
|
|
54
|
+
expect(result.password).not.toBe(rawPassword);
|
|
55
|
+
expect(result.password.length).toBe(64);
|
|
56
|
+
expect(/^[0-9a-f]{64}$/.test(result.password)).toBe(true);
|
|
57
|
+
expect(result.passwordTransmission).toBe('hash');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('hash mode with missing crypto.subtle - falls back to pure JS SHA-256', async () => {
|
|
61
|
+
const originalDigest = crypto.subtle.digest;
|
|
62
|
+
// Simulate missing Web Crypto API by making digest throw
|
|
63
|
+
(crypto.subtle as { digest: typeof crypto.subtle.digest }).digest = () => {
|
|
64
|
+
throw new Error('Not available');
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const policy = createPolicy({ mode: 'hash' });
|
|
68
|
+
const result = await processPasswordForTransmission(rawPassword, policy);
|
|
69
|
+
|
|
70
|
+
crypto.subtle.digest = originalDigest;
|
|
71
|
+
|
|
72
|
+
expect(result.password).not.toBe(rawPassword);
|
|
73
|
+
expect(result.password.length).toBe(64);
|
|
74
|
+
expect(/^[0-9a-f]{64}$/.test(result.password)).toBe(true);
|
|
75
|
+
expect(result.passwordTransmission).toBe('hash');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('symmetric mode - requires keyExchangeFn, throws if missing', async () => {
|
|
79
|
+
const policy = createPolicy({ mode: 'symmetric' });
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
processPasswordForTransmission(rawPassword, policy),
|
|
83
|
+
).rejects.toThrow('keyExchangeFn is required for symmetric mode');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('symmetric mode with mock keyExchangeFn - returns encrypted result with correct transmission', async () => {
|
|
87
|
+
const policy = createPolicy({ mode: 'symmetric' });
|
|
88
|
+
const result = await processPasswordForTransmission(rawPassword, policy, mockKeyExchange);
|
|
89
|
+
|
|
90
|
+
expect(result.password).not.toBe(rawPassword);
|
|
91
|
+
expect(result.passwordTransmission).toBe('symmetric');
|
|
92
|
+
expect(result.keyExchangeId).toBe('ke_123');
|
|
93
|
+
expect(typeof result.password).toBe('string');
|
|
94
|
+
expect(result.password.length).toBeGreaterThan(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('asymmetric mode - requires publicKey in policy, throws if missing or too short', async () => {
|
|
98
|
+
const policyMissing = createPolicy({ mode: 'asymmetric', publicKey: '' });
|
|
99
|
+
await expect(
|
|
100
|
+
processPasswordForTransmission(rawPassword, policyMissing),
|
|
101
|
+
).rejects.toThrow('public_key is required for asymmetric mode');
|
|
102
|
+
|
|
103
|
+
const policyShort = createPolicy({ mode: 'asymmetric', publicKey: 'short' });
|
|
104
|
+
await expect(
|
|
105
|
+
processPasswordForTransmission(rawPassword, policyShort),
|
|
106
|
+
).rejects.toThrow('public_key is required for asymmetric mode');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('asymmetric mode with valid publicKey - returns encrypted result with transmission asymmetric', async () => {
|
|
110
|
+
const validPEM = await generateRsaPublicKeyPEM();
|
|
111
|
+
const policy = createPolicy({ mode: 'asymmetric', publicKey: validPEM });
|
|
112
|
+
const result = await processPasswordForTransmission(rawPassword, policy);
|
|
113
|
+
|
|
114
|
+
expect(result.password).not.toBe(rawPassword);
|
|
115
|
+
expect(result.passwordTransmission).toBe('asymmetric');
|
|
116
|
+
expect(typeof result.password).toBe('string');
|
|
117
|
+
expect(result.password.length).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('undefined/empty mode - defaults to plain', async () => {
|
|
121
|
+
const policyUndefined = createPolicy({ mode: undefined as unknown as string });
|
|
122
|
+
const result1 = await processPasswordForTransmission(rawPassword, policyUndefined);
|
|
123
|
+
expect(result1.password).toBe(rawPassword);
|
|
124
|
+
expect(result1.passwordTransmission).toBe('plain');
|
|
125
|
+
|
|
126
|
+
const policyEmpty = createPolicy({ mode: '' });
|
|
127
|
+
const result2 = await processPasswordForTransmission(rawPassword, policyEmpty);
|
|
128
|
+
expect(result2.password).toBe(rawPassword);
|
|
129
|
+
expect(result2.passwordTransmission).toBe('plain');
|
|
130
|
+
});
|
|
131
|
+
});
|