ryauth 1.0.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/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/docs/api-reference.md +203 -0
- package/docs/examples.md +225 -0
- package/index.js +15 -0
- package/package.json +37 -0
- package/src/adapters/base.js +81 -0
- package/src/adapters/memory.js +159 -0
- package/src/core/crypto.js +103 -0
- package/src/middleware/auth.js +127 -0
- package/src/services/auth-service.js +172 -0
- package/tests/adapters.test.js +355 -0
- package/tests/auth-service.test.js +287 -0
- package/tests/crypto.test.js +202 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { BaseAdapter } from '../src/adapters/base.js';
|
|
2
|
+
import { MemoryAdapter } from '../src/adapters/memory.js';
|
|
3
|
+
|
|
4
|
+
describe('Data Adapters - BaseAdapter Contract', () => {
|
|
5
|
+
let baseAdapter;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
baseAdapter = new BaseAdapter();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('BaseAdapter - Interface Methods', () => {
|
|
12
|
+
it('should throw "Not Implemented" error for findUserByEmail', async () => {
|
|
13
|
+
await expect(baseAdapter.findUserByEmail('test@example.com'))
|
|
14
|
+
.rejects
|
|
15
|
+
.toThrow('Method findUserByEmail() must be implemented');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should throw "Not Implemented" error for createUser', async () => {
|
|
19
|
+
await expect(baseAdapter.createUser({ email: 'test@example.com', hashedPassword: 'hash' }))
|
|
20
|
+
.rejects
|
|
21
|
+
.toThrow('Method createUser() must be implemented');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should throw "Not Implemented" error for saveRefreshToken', async () => {
|
|
25
|
+
await expect(baseAdapter.saveRefreshToken('user123', 'token123', new Date()))
|
|
26
|
+
.rejects
|
|
27
|
+
.toThrow('Method saveRefreshToken() must be implemented');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should throw "Not Implemented" error for isRefreshTokenValid', async () => {
|
|
31
|
+
await expect(baseAdapter.isRefreshTokenValid('token123'))
|
|
32
|
+
.rejects
|
|
33
|
+
.toThrow('Method isRefreshTokenValid() must be implemented');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should throw "Not Implemented" error for revokeRefreshToken', async () => {
|
|
37
|
+
await expect(baseAdapter.revokeRefreshToken('token123'))
|
|
38
|
+
.rejects
|
|
39
|
+
.toThrow('Method revokeRefreshToken() must be implemented');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should throw "Not Implemented" error for revokeAllUserSessions', async () => {
|
|
43
|
+
await expect(baseAdapter.revokeAllUserSessions('user123'))
|
|
44
|
+
.rejects
|
|
45
|
+
.toThrow('Method revokeAllUserSessions() must be implemented');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Data Adapters - MemoryAdapter Implementation', () => {
|
|
51
|
+
let memoryAdapter;
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
memoryAdapter = new MemoryAdapter();
|
|
55
|
+
await memoryAdapter.clear();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('findUserByEmail', () => {
|
|
59
|
+
it('should correctly retrieve a user by email', async () => {
|
|
60
|
+
// Create a user first
|
|
61
|
+
const userData = {
|
|
62
|
+
email: 'test@example.com',
|
|
63
|
+
hashedPassword: 'hashed_password_123',
|
|
64
|
+
role: 'user'
|
|
65
|
+
};
|
|
66
|
+
const createdUser = await memoryAdapter.createUser(userData);
|
|
67
|
+
|
|
68
|
+
// Find the user
|
|
69
|
+
const foundUser = await memoryAdapter.findUserByEmail('test@example.com');
|
|
70
|
+
|
|
71
|
+
expect(foundUser).not.toBeNull();
|
|
72
|
+
expect(foundUser.id).toBe(createdUser.id);
|
|
73
|
+
expect(foundUser.email).toBe('test@example.com');
|
|
74
|
+
expect(foundUser.hashedPassword).toBe('hashed_password_123');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return null for non-existent user', async () => {
|
|
78
|
+
const user = await memoryAdapter.findUserByEmail('nonexistent@example.com');
|
|
79
|
+
expect(user).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should throw error if email is not a string', async () => {
|
|
83
|
+
await expect(memoryAdapter.findUserByEmail(123))
|
|
84
|
+
.rejects
|
|
85
|
+
.toThrow('Email must be a string');
|
|
86
|
+
|
|
87
|
+
await expect(memoryAdapter.findUserByEmail(null))
|
|
88
|
+
.rejects
|
|
89
|
+
.toThrow('Email must be a string');
|
|
90
|
+
|
|
91
|
+
await expect(memoryAdapter.findUserByEmail({}))
|
|
92
|
+
.rejects
|
|
93
|
+
.toThrow('Email must be a string');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('createUser', () => {
|
|
98
|
+
it('should create a user with valid data', async () => {
|
|
99
|
+
const userData = {
|
|
100
|
+
email: 'newuser@example.com',
|
|
101
|
+
hashedPassword: 'hashed_password_456',
|
|
102
|
+
role: 'admin'
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const createdUser = await memoryAdapter.createUser(userData);
|
|
106
|
+
|
|
107
|
+
expect(createdUser).toHaveProperty('id');
|
|
108
|
+
expect(createdUser.email).toBe('newuser@example.com');
|
|
109
|
+
expect(createdUser.hashedPassword).toBe('hashed_password_456');
|
|
110
|
+
expect(createdUser.role).toBe('admin');
|
|
111
|
+
expect(createdUser).toHaveProperty('createdAt');
|
|
112
|
+
expect(createdUser.createdAt instanceof Date).toBeTruthy();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should generate unique IDs for different users', async () => {
|
|
116
|
+
const user1 = await memoryAdapter.createUser({
|
|
117
|
+
email: 'user1@example.com',
|
|
118
|
+
hashedPassword: 'hash1'
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const user2 = await memoryAdapter.createUser({
|
|
122
|
+
email: 'user2@example.com',
|
|
123
|
+
hashedPassword: 'hash2'
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(user1.id).not.toBe(user2.id);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should throw error for duplicate email', async () => {
|
|
130
|
+
await memoryAdapter.createUser({
|
|
131
|
+
email: 'duplicate@example.com',
|
|
132
|
+
hashedPassword: 'hash1'
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await expect(memoryAdapter.createUser({
|
|
136
|
+
email: 'duplicate@example.com',
|
|
137
|
+
hashedPassword: 'hash2'
|
|
138
|
+
}))
|
|
139
|
+
.rejects
|
|
140
|
+
.toThrow('User with this email already exists');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should set default role if not provided', async () => {
|
|
144
|
+
const user = await memoryAdapter.createUser({
|
|
145
|
+
email: 'norole@example.com',
|
|
146
|
+
hashedPassword: 'hash1'
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(user.role).toBe('user');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should validate email format', async () => {
|
|
153
|
+
await expect(memoryAdapter.createUser({
|
|
154
|
+
email: 'invalid-email',
|
|
155
|
+
hashedPassword: 'hash1'
|
|
156
|
+
}))
|
|
157
|
+
.rejects
|
|
158
|
+
.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should require hashedPassword to be a string', async () => {
|
|
162
|
+
await expect(memoryAdapter.createUser({
|
|
163
|
+
email: 'test@example.com',
|
|
164
|
+
hashedPassword: 123
|
|
165
|
+
}))
|
|
166
|
+
.rejects
|
|
167
|
+
.toThrow();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('saveRefreshToken', () => {
|
|
172
|
+
it('should persist token with correct expiry', async () => {
|
|
173
|
+
const expiresAt = new Date();
|
|
174
|
+
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days from now
|
|
175
|
+
|
|
176
|
+
await memoryAdapter.saveRefreshToken('user123', 'refresh_token_abc', expiresAt);
|
|
177
|
+
|
|
178
|
+
const isValid = await memoryAdapter.isRefreshTokenValid('refresh_token_abc');
|
|
179
|
+
expect(isValid).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should throw error if userId is not a string', async () => {
|
|
183
|
+
await expect(memoryAdapter.saveRefreshToken(123, 'token', new Date()))
|
|
184
|
+
.rejects
|
|
185
|
+
.toThrow('Invalid parameters');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should throw error if token is not a string', async () => {
|
|
189
|
+
await expect(memoryAdapter.saveRefreshToken('user123', 123, new Date()))
|
|
190
|
+
.rejects
|
|
191
|
+
.toThrow('Invalid parameters');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should throw error if expiresAt is not a Date', async () => {
|
|
195
|
+
await expect(memoryAdapter.saveRefreshToken('user123', 'token', 'not-a-date'))
|
|
196
|
+
.rejects
|
|
197
|
+
.toThrow('Invalid parameters');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should throw error if token is too short', async () => {
|
|
201
|
+
await expect(memoryAdapter.saveRefreshToken('user123', 'short', new Date()))
|
|
202
|
+
.rejects
|
|
203
|
+
.toThrow('Token too short');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should allow token to be reused after revocation', async () => {
|
|
207
|
+
// First save
|
|
208
|
+
await memoryAdapter.saveRefreshToken('user123', 'longtoken123', new Date(Date.now() + 86400000));
|
|
209
|
+
|
|
210
|
+
// Revoke it
|
|
211
|
+
await memoryAdapter.revokeRefreshToken('longtoken123');
|
|
212
|
+
|
|
213
|
+
// Verify it's revoked
|
|
214
|
+
expect(await memoryAdapter.isRefreshTokenValid('longtoken123')).toBe(false);
|
|
215
|
+
|
|
216
|
+
// Save again (should work)
|
|
217
|
+
await memoryAdapter.saveRefreshToken('user123', 'longtoken123', new Date(Date.now() + 86400000));
|
|
218
|
+
|
|
219
|
+
// Verify it's valid again
|
|
220
|
+
expect(await memoryAdapter.isRefreshTokenValid('longtoken123')).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('isRefreshTokenValid', () => {
|
|
225
|
+
it('should return true for valid token', async () => {
|
|
226
|
+
const expiresAt = new Date();
|
|
227
|
+
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
228
|
+
|
|
229
|
+
await memoryAdapter.saveRefreshToken('user123', 'valid_token', expiresAt);
|
|
230
|
+
|
|
231
|
+
const isValid = await memoryAdapter.isRefreshTokenValid('valid_token');
|
|
232
|
+
expect(isValid).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should return false for non-existent token', async () => {
|
|
236
|
+
const isValid = await memoryAdapter.isRefreshTokenValid('nonexistent_token');
|
|
237
|
+
expect(isValid).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should return false for revoked token', async () => {
|
|
241
|
+
const expiresAt = new Date();
|
|
242
|
+
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
243
|
+
|
|
244
|
+
await memoryAdapter.saveRefreshToken('user123', 'revoked_token', expiresAt);
|
|
245
|
+
await memoryAdapter.revokeRefreshToken('revoked_token');
|
|
246
|
+
|
|
247
|
+
const isValid = await memoryAdapter.isRefreshTokenValid('revoked_token');
|
|
248
|
+
expect(isValid).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should return false for expired token', async () => {
|
|
252
|
+
const expiresAt = new Date();
|
|
253
|
+
expiresAt.setDate(expiresAt.getDate() - 1); // Expired yesterday
|
|
254
|
+
|
|
255
|
+
await memoryAdapter.saveRefreshToken('user123', 'expired_token', expiresAt);
|
|
256
|
+
|
|
257
|
+
const isValid = await memoryAdapter.isRefreshTokenValid('expired_token');
|
|
258
|
+
expect(isValid).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should throw error if token is not a string', async () => {
|
|
262
|
+
await expect(memoryAdapter.isRefreshTokenValid(123))
|
|
263
|
+
.rejects
|
|
264
|
+
.toThrow('Token must be a string');
|
|
265
|
+
|
|
266
|
+
await expect(memoryAdapter.isRefreshTokenValid(null))
|
|
267
|
+
.rejects
|
|
268
|
+
.toThrow('Token must be a string');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('revokeRefreshToken', () => {
|
|
273
|
+
it('should revoke a single token', async () => {
|
|
274
|
+
const expiresAt = new Date();
|
|
275
|
+
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
276
|
+
|
|
277
|
+
await memoryAdapter.saveRefreshToken('user123', 'token_to_revoke', expiresAt);
|
|
278
|
+
|
|
279
|
+
// Verify it's valid before revocation
|
|
280
|
+
expect(await memoryAdapter.isRefreshTokenValid('token_to_revoke')).toBe(true);
|
|
281
|
+
|
|
282
|
+
// Revoke it
|
|
283
|
+
await memoryAdapter.revokeRefreshToken('token_to_revoke');
|
|
284
|
+
|
|
285
|
+
// Verify it's revoked
|
|
286
|
+
expect(await memoryAdapter.isRefreshTokenValid('token_to_revoke')).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should throw error if token is not a string', async () => {
|
|
290
|
+
await expect(memoryAdapter.revokeRefreshToken(123))
|
|
291
|
+
.rejects
|
|
292
|
+
.toThrow('Token must be a string');
|
|
293
|
+
|
|
294
|
+
await expect(memoryAdapter.revokeRefreshToken(null))
|
|
295
|
+
.rejects
|
|
296
|
+
.toThrow('Token must be a string');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should not throw error when revoking non-existent token', async () => {
|
|
300
|
+
// Should not throw even if token doesn't exist
|
|
301
|
+
await expect(memoryAdapter.revokeRefreshToken('nonexistent_token'))
|
|
302
|
+
.resolves
|
|
303
|
+
.not.toThrow();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('revokeAllUserSessions', () => {
|
|
308
|
+
it('should invalidate every token for a specific userId', async () => {
|
|
309
|
+
const expiresAt = new Date();
|
|
310
|
+
expiresAt.setDate(expiresAt.getDate() + 7);
|
|
311
|
+
|
|
312
|
+
// Create tokens for user123
|
|
313
|
+
await memoryAdapter.saveRefreshToken('user123', 'token1_user123', expiresAt);
|
|
314
|
+
await memoryAdapter.saveRefreshToken('user123', 'token2_user123', expiresAt);
|
|
315
|
+
await memoryAdapter.saveRefreshToken('user123', 'token3_user123', expiresAt);
|
|
316
|
+
|
|
317
|
+
// Create tokens for user456 (should not be affected)
|
|
318
|
+
await memoryAdapter.saveRefreshToken('user456', 'token1_user456', expiresAt);
|
|
319
|
+
|
|
320
|
+
// Verify all tokens are valid before revocation
|
|
321
|
+
expect(await memoryAdapter.isRefreshTokenValid('token1_user123')).toBe(true);
|
|
322
|
+
expect(await memoryAdapter.isRefreshTokenValid('token2_user123')).toBe(true);
|
|
323
|
+
expect(await memoryAdapter.isRefreshTokenValid('token3_user123')).toBe(true);
|
|
324
|
+
expect(await memoryAdapter.isRefreshTokenValid('token1_user456')).toBe(true);
|
|
325
|
+
|
|
326
|
+
// Revoke all sessions for user123
|
|
327
|
+
await memoryAdapter.revokeAllUserSessions('user123');
|
|
328
|
+
|
|
329
|
+
// Verify user123's tokens are revoked
|
|
330
|
+
expect(await memoryAdapter.isRefreshTokenValid('token1_user123')).toBe(false);
|
|
331
|
+
expect(await memoryAdapter.isRefreshTokenValid('token2_user123')).toBe(false);
|
|
332
|
+
expect(await memoryAdapter.isRefreshTokenValid('token3_user123')).toBe(false);
|
|
333
|
+
|
|
334
|
+
// Verify user456's token is still valid
|
|
335
|
+
expect(await memoryAdapter.isRefreshTokenValid('token1_user456')).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should throw error if userId is not a string', async () => {
|
|
339
|
+
await expect(memoryAdapter.revokeAllUserSessions(123))
|
|
340
|
+
.rejects
|
|
341
|
+
.toThrow('User ID must be a string');
|
|
342
|
+
|
|
343
|
+
await expect(memoryAdapter.revokeAllUserSessions(null))
|
|
344
|
+
.rejects
|
|
345
|
+
.toThrow('User ID must be a string');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should not throw error when revoking sessions for non-existent user', async () => {
|
|
349
|
+
// Should not throw even if user doesn't exist
|
|
350
|
+
await expect(memoryAdapter.revokeAllUserSessions('nonexistent_user'))
|
|
351
|
+
.resolves
|
|
352
|
+
.not.toThrow();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
2
|
+
import { AuthService } from '../src/services/auth-service.js';
|
|
3
|
+
import { MemoryAdapter } from '../src/adapters/memory.js';
|
|
4
|
+
import { hashPassword } from '../src/core/crypto.js';
|
|
5
|
+
|
|
6
|
+
// Mock environment variables
|
|
7
|
+
const originalEnv = process.env;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
jest.resetModules();
|
|
11
|
+
process.env = { ...originalEnv };
|
|
12
|
+
process.env.ACCESS_TOKEN_SECRET = 'test_access_secret_32_characters_long';
|
|
13
|
+
process.env.REFRESH_TOKEN_SECRET = 'test_refresh_secret_32_characters_long';
|
|
14
|
+
|
|
15
|
+
// Clear adapter state
|
|
16
|
+
const adapter = new MemoryAdapter();
|
|
17
|
+
adapter.clear();
|
|
18
|
+
|
|
19
|
+
// Create test user
|
|
20
|
+
const testUser = {
|
|
21
|
+
id: 'test-user-id',
|
|
22
|
+
email: 'test@example.com',
|
|
23
|
+
hashedPassword: await hashPassword('password123'),
|
|
24
|
+
role: 'user'
|
|
25
|
+
};
|
|
26
|
+
await adapter.createUser(testUser);
|
|
27
|
+
|
|
28
|
+
// Save a valid refresh token for the test user
|
|
29
|
+
await adapter.saveRefreshToken(
|
|
30
|
+
testUser.id,
|
|
31
|
+
'valid-refresh-token-abc123',
|
|
32
|
+
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Store adapter in a global variable so tests can use it
|
|
36
|
+
global.testAdapter = adapter;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
process.env = originalEnv;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('AuthService - Registration', () => {
|
|
44
|
+
it('should successfully register a new user', async () => {
|
|
45
|
+
const adapter = new MemoryAdapter();
|
|
46
|
+
adapter.clear();
|
|
47
|
+
const service = new AuthService(adapter);
|
|
48
|
+
|
|
49
|
+
const result = await service.register('newuser@example.com', 'securePassword123');
|
|
50
|
+
|
|
51
|
+
expect(result.success).toBe(true);
|
|
52
|
+
expect(result.userId).toBeDefined();
|
|
53
|
+
expect(typeof result.userId).toBe('string');
|
|
54
|
+
|
|
55
|
+
// Verify user was created in adapter
|
|
56
|
+
const user = await adapter.findUserByEmail('newuser@example.com');
|
|
57
|
+
expect(user).toBeDefined();
|
|
58
|
+
expect(user.email).toBe('newuser@example.com');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should throw error for duplicate email', async () => {
|
|
62
|
+
const adapter = new MemoryAdapter();
|
|
63
|
+
const service = new AuthService(adapter);
|
|
64
|
+
|
|
65
|
+
// First registration should succeed
|
|
66
|
+
await service.register('duplicate@example.com', 'password123');
|
|
67
|
+
|
|
68
|
+
// Second registration should fail
|
|
69
|
+
await expect(service.register('duplicate@example.com', 'password456'))
|
|
70
|
+
.rejects
|
|
71
|
+
.toThrow('User already exists');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should throw error for invalid email format', async () => {
|
|
75
|
+
const adapter = new MemoryAdapter();
|
|
76
|
+
const service = new AuthService(adapter);
|
|
77
|
+
|
|
78
|
+
await expect(service.register('invalid-email', 'password123'))
|
|
79
|
+
.rejects
|
|
80
|
+
.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should throw error for short password', async () => {
|
|
84
|
+
const adapter = new MemoryAdapter();
|
|
85
|
+
const service = new AuthService(adapter);
|
|
86
|
+
|
|
87
|
+
await expect(service.register('user@example.com', 'short'))
|
|
88
|
+
.rejects
|
|
89
|
+
.toThrow('Password must be at least 8 characters');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('AuthService - Login', () => {
|
|
94
|
+
it('should successfully login with correct credentials', async () => {
|
|
95
|
+
const service = new AuthService(global.testAdapter);
|
|
96
|
+
|
|
97
|
+
const result = await service.login('test@example.com', 'password123');
|
|
98
|
+
|
|
99
|
+
expect(result.success).toBe(true);
|
|
100
|
+
expect(result.accessToken).toBeDefined();
|
|
101
|
+
expect(result.refreshToken).toBeDefined();
|
|
102
|
+
expect(typeof result.accessToken).toBe('string');
|
|
103
|
+
expect(typeof result.refreshToken).toBe('string');
|
|
104
|
+
|
|
105
|
+
// Verify refresh token was saved
|
|
106
|
+
const isValid = await global.testAdapter.isRefreshTokenValid(result.refreshToken);
|
|
107
|
+
expect(isValid).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should throw error for invalid credentials (wrong password)', async () => {
|
|
111
|
+
const service = new AuthService(global.testAdapter);
|
|
112
|
+
|
|
113
|
+
await expect(service.login('test@example.com', 'wrongpassword'))
|
|
114
|
+
.rejects
|
|
115
|
+
.toThrow('Invalid credentials');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should throw error for invalid credentials (non-existent user)', async () => {
|
|
119
|
+
const service = new AuthService(global.testAdapter);
|
|
120
|
+
|
|
121
|
+
await expect(service.login('nonexistent@example.com', 'password123'))
|
|
122
|
+
.rejects
|
|
123
|
+
.toThrow('Invalid credentials');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should maintain timing safety for invalid credentials', async () => {
|
|
127
|
+
const service = new AuthService(global.testAdapter);
|
|
128
|
+
|
|
129
|
+
let startTime = Date.now();
|
|
130
|
+
|
|
131
|
+
// Test with non-existent user
|
|
132
|
+
try {
|
|
133
|
+
await service.login('nonexistent@example.com', 'password123');
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// Expected to fail
|
|
136
|
+
}
|
|
137
|
+
const time1 = Date.now() - startTime;
|
|
138
|
+
|
|
139
|
+
// Test with existing user but wrong password
|
|
140
|
+
startTime = Date.now();
|
|
141
|
+
try {
|
|
142
|
+
await service.login('test@example.com', 'wrongpassword');
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Expected to fail
|
|
145
|
+
}
|
|
146
|
+
const time2 = Date.now() - startTime;
|
|
147
|
+
|
|
148
|
+
// Times should be similar (within 50ms tolerance)
|
|
149
|
+
expect(Math.abs(time1 - time2)).toBeLessThan(50);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('AuthService - Token Rotation', () => {
|
|
154
|
+
it('should successfully rotate tokens', async () => {
|
|
155
|
+
const service = new AuthService(global.testAdapter);
|
|
156
|
+
|
|
157
|
+
// First login to get initial tokens
|
|
158
|
+
const loginResult = await service.login('test@example.com', 'password123');
|
|
159
|
+
const oldRefreshToken = loginResult.refreshToken;
|
|
160
|
+
|
|
161
|
+
// Rotate tokens
|
|
162
|
+
const refreshResult = await service.refresh(oldRefreshToken);
|
|
163
|
+
|
|
164
|
+
expect(refreshResult.success).toBe(true);
|
|
165
|
+
expect(refreshResult.accessToken).toBeDefined();
|
|
166
|
+
expect(refreshResult.refreshToken).toBeDefined();
|
|
167
|
+
expect(refreshResult.accessToken).not.toBe(oldRefreshToken);
|
|
168
|
+
expect(refreshResult.refreshToken).not.toBe(oldRefreshToken);
|
|
169
|
+
|
|
170
|
+
// Verify old refresh token is revoked
|
|
171
|
+
const isOldTokenValid = await global.testAdapter.isRefreshTokenValid(oldRefreshToken);
|
|
172
|
+
expect(isOldTokenValid).toBe(false);
|
|
173
|
+
|
|
174
|
+
// Verify new refresh token is valid
|
|
175
|
+
const isNewTokenValid = await global.testAdapter.isRefreshTokenValid(refreshResult.refreshToken);
|
|
176
|
+
expect(isNewTokenValid).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should throw error for invalid refresh token', async () => {
|
|
180
|
+
const service = new AuthService(global.testAdapter);
|
|
181
|
+
|
|
182
|
+
await expect(service.refresh('invalid-token'))
|
|
183
|
+
.rejects
|
|
184
|
+
.toThrow('Invalid refresh token');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should throw error for revoked refresh token', async () => {
|
|
188
|
+
const service = new AuthService(global.testAdapter);
|
|
189
|
+
|
|
190
|
+
// Login to get a token
|
|
191
|
+
const loginResult = await service.login('test@example.com', 'password123');
|
|
192
|
+
const refreshToken = loginResult.refreshToken;
|
|
193
|
+
|
|
194
|
+
// Revoke the token
|
|
195
|
+
await global.testAdapter.revokeRefreshToken(refreshToken);
|
|
196
|
+
|
|
197
|
+
// Try to use revoked token
|
|
198
|
+
await expect(service.refresh(refreshToken))
|
|
199
|
+
.rejects
|
|
200
|
+
.toThrow('Session revoked - please login again');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should automatically revoke all sessions when refresh token is reused', async () => {
|
|
204
|
+
const service = new AuthService(global.testAdapter);
|
|
205
|
+
|
|
206
|
+
// Login to get initial tokens
|
|
207
|
+
const loginResult = await service.login('test@example.com', 'password123');
|
|
208
|
+
const firstRefreshToken = loginResult.refreshToken;
|
|
209
|
+
|
|
210
|
+
// Rotate once
|
|
211
|
+
const refreshResult1 = await service.refresh(firstRefreshToken);
|
|
212
|
+
const secondRefreshToken = refreshResult1.refreshToken;
|
|
213
|
+
|
|
214
|
+
// Rotate again
|
|
215
|
+
const refreshResult2 = await service.refresh(secondRefreshToken);
|
|
216
|
+
const thirdRefreshToken = refreshResult2.refreshToken;
|
|
217
|
+
|
|
218
|
+
// Now try to use the first refresh token (which was revoked)
|
|
219
|
+
await expect(service.refresh(firstRefreshToken))
|
|
220
|
+
.rejects
|
|
221
|
+
.toThrow('Session revoked - please login again');
|
|
222
|
+
|
|
223
|
+
// Verify all tokens for this user are now revoked
|
|
224
|
+
const isFirstValid = await global.testAdapter.isRefreshTokenValid(firstRefreshToken);
|
|
225
|
+
const isSecondValid = await global.testAdapter.isRefreshTokenValid(secondRefreshToken);
|
|
226
|
+
const isThirdValid = await global.testAdapter.isRefreshTokenValid(thirdRefreshToken);
|
|
227
|
+
|
|
228
|
+
expect(isFirstValid).toBe(false);
|
|
229
|
+
expect(isSecondValid).toBe(false);
|
|
230
|
+
expect(isThirdValid).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should throw error for expired refresh token', async () => {
|
|
234
|
+
const service = new AuthService(global.testAdapter);
|
|
235
|
+
|
|
236
|
+
// Create a user
|
|
237
|
+
await service.register('expired@example.com', 'password123');
|
|
238
|
+
|
|
239
|
+
// Login to get tokens
|
|
240
|
+
const loginResult = await service.login('expired@example.com', 'password123');
|
|
241
|
+
const refreshToken = loginResult.refreshToken;
|
|
242
|
+
|
|
243
|
+
// Manually revoke the token to simulate expiration
|
|
244
|
+
await global.testAdapter.revokeRefreshToken(refreshToken);
|
|
245
|
+
|
|
246
|
+
// Try to use expired token
|
|
247
|
+
await expect(service.refresh(refreshToken))
|
|
248
|
+
.rejects
|
|
249
|
+
.toThrow('Session revoked - please login again');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should throw error for short refresh token', async () => {
|
|
253
|
+
const service = new AuthService(global.testAdapter);
|
|
254
|
+
|
|
255
|
+
await expect(service.refresh('short'))
|
|
256
|
+
.rejects
|
|
257
|
+
.toThrow('Refresh token is required');
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('AuthService - Integration with MemoryAdapter', () => {
|
|
262
|
+
it('should maintain proper state across multiple operations', async () => {
|
|
263
|
+
const adapter = new MemoryAdapter();
|
|
264
|
+
adapter.clear();
|
|
265
|
+
const service = new AuthService(adapter);
|
|
266
|
+
|
|
267
|
+
// Register multiple users
|
|
268
|
+
const user1 = await service.register('user1@example.com', 'password123');
|
|
269
|
+
const user2 = await service.register('user2@example.com', 'password456');
|
|
270
|
+
|
|
271
|
+
// Login both users
|
|
272
|
+
const login1 = await service.login('user1@example.com', 'password123');
|
|
273
|
+
const login2 = await service.login('user2@example.com', 'password456');
|
|
274
|
+
|
|
275
|
+
// Verify tokens are saved
|
|
276
|
+
expect(await adapter.isRefreshTokenValid(login1.refreshToken)).toBe(true);
|
|
277
|
+
expect(await adapter.isRefreshTokenValid(login2.refreshToken)).toBe(true);
|
|
278
|
+
|
|
279
|
+
// Rotate user1's token
|
|
280
|
+
const refresh1 = await service.refresh(login1.refreshToken);
|
|
281
|
+
|
|
282
|
+
// Verify user1's old token is revoked but user2's token is still valid
|
|
283
|
+
expect(await adapter.isRefreshTokenValid(login1.refreshToken)).toBe(false);
|
|
284
|
+
expect(await adapter.isRefreshTokenValid(login2.refreshToken)).toBe(true);
|
|
285
|
+
expect(await adapter.isRefreshTokenValid(refresh1.refreshToken)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|