create-tigra 2.8.0 → 3.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.
Files changed (44) hide show
  1. package/README.md +10 -3
  2. package/bin/create-tigra.js +77 -37
  3. package/package.json +5 -5
  4. package/template/_claude/commands/create-server.md +8 -2
  5. package/template/_claude/rules/client/01-project-structure.md +12 -0
  6. package/template/_claude/rules/client/03-data-and-state.md +1 -1
  7. package/template/_claude/rules/client/04-design-system.md +23 -0
  8. package/template/_claude/rules/client/07-deployment.md +99 -0
  9. package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
  10. package/template/_claude/rules/client/core.md +1 -0
  11. package/template/_claude/rules/global/core.md +20 -1
  12. package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
  13. package/template/_claude/rules/server/core.md +2 -0
  14. package/template/_claude/rules/server/deployment.md +78 -0
  15. package/template/client/next.config.ts +12 -2
  16. package/template/client/package-lock.json +12345 -0
  17. package/template/client/package.json +3 -2
  18. package/template/client/src/components/common/SafeImage.tsx +2 -1
  19. package/template/client/src/lib/api/axios.config.ts +19 -4
  20. package/template/client/src/middleware.ts +7 -0
  21. package/template/gitignore +1 -0
  22. package/template/server/.env.example +42 -0
  23. package/template/server/.env.example.production +40 -0
  24. package/template/server/Dockerfile +29 -5
  25. package/template/server/docker-compose.yml +15 -4
  26. package/template/server/package-lock.json +6544 -6823
  27. package/template/server/package.json +76 -75
  28. package/template/server/prisma/seed.ts +20 -4
  29. package/template/server/src/app.ts +40 -8
  30. package/template/server/src/config/env.ts +72 -28
  31. package/template/server/src/config/rate-limit.config.ts +16 -0
  32. package/template/server/src/libs/__tests__/http.test.ts +23 -9
  33. package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
  34. package/template/server/src/libs/auth.ts +6 -16
  35. package/template/server/src/libs/cookies.ts +1 -1
  36. package/template/server/src/libs/duration.ts +30 -0
  37. package/template/server/src/libs/ip-block.ts +10 -4
  38. package/template/server/src/libs/origin-check.ts +38 -0
  39. package/template/server/src/libs/redis.ts +1 -1
  40. package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
  41. package/template/server/src/modules/auth/auth.repo.ts +2 -0
  42. package/template/server/src/modules/auth/auth.service.ts +103 -12
  43. package/template/server/src/test/setup.ts +22 -2
  44. package/template/server/vitest.config.ts +43 -43
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Duration parsing — LEAF MODULE.
3
+ *
4
+ * This file must have ZERO imports. It exists to break the circular import
5
+ * between cookies.ts and auth.ts: cookies.ts calls parseDurationMs at module
6
+ * top-level, so if it imported the function from auth.ts (which imports
7
+ * clearAuthCookies from cookies.ts), the cycle left auth.ts partially
8
+ * initialized and crashed module evaluation with
9
+ * "parseDurationMs is not a function" (TDZ). Keep this module dependency-free.
10
+ */
11
+
12
+ /**
13
+ * Parse a duration string like "15m", "7d", "30s", "12h" into milliseconds.
14
+ * Returns `fallbackMs` when the string doesn't match the expected format.
15
+ */
16
+ export function parseDurationMs(duration: string, fallbackMs: number): number {
17
+ const match = duration.match(/^(\d+)([smhd])$/);
18
+ if (!match) return fallbackMs;
19
+
20
+ const value = parseInt(match[1], 10);
21
+ const unit = match[2];
22
+ const multipliers: Record<string, number> = {
23
+ s: 1000,
24
+ m: 60 * 1000,
25
+ h: 60 * 60 * 1000,
26
+ d: 24 * 60 * 60 * 1000,
27
+ };
28
+
29
+ return value * multipliers[unit];
30
+ }
@@ -13,6 +13,7 @@
13
13
  * - Lazy cleanup: expired auto-blocks are removed on check, no separate cleanup job needed
14
14
  */
15
15
 
16
+ import { env } from '@config/env.js';
16
17
  import { prisma } from '@libs/prisma.js';
17
18
  import { getRedis } from '@libs/redis.js';
18
19
  import { logger } from '@libs/logger.js';
@@ -23,10 +24,15 @@ const BLOCKED_IPS_KEY = 'blocked_ips';
23
24
  const AUTO_BLOCKED_KEY = 'auto_blocked_ips';
24
25
  const VIOLATION_PREFIX = 'rl_violations:';
25
26
 
26
- // Auto-block thresholds
27
- const AUTO_BLOCK_THRESHOLD = 10; // violations before auto-block
28
- const AUTO_BLOCK_WINDOW_SECONDS = 300; // 5-minute sliding window
29
- const AUTO_BLOCK_DURATION_SECONDS = 3600; // block for 1 hour
27
+ // Auto-block thresholds — env-configurable (see .env.example).
28
+ // The threshold default is deliberately high (20): auto-block must catch
29
+ // SUSTAINED abuse, not a single burst. Rate-limit counting happens before
30
+ // validation, so a retry-looping but legitimate client (or a NAT'd office
31
+ // sharing one egress IP) can rack up violations quickly — see the self-ban
32
+ // interaction note in src/config/rate-limit.config.ts before lowering it.
33
+ const AUTO_BLOCK_THRESHOLD = env.IP_AUTO_BLOCK_THRESHOLD; // violations before auto-block (default 20)
34
+ const AUTO_BLOCK_WINDOW_SECONDS = env.IP_AUTO_BLOCK_WINDOW_SECONDS; // sliding window (default 300 = 5 min)
35
+ const AUTO_BLOCK_DURATION_SECONDS = env.IP_AUTO_BLOCK_DURATION_SECONDS; // block duration (default 3600 = 1 hour)
30
36
 
31
37
  /**
32
38
  * Sync all permanent blocked IPs from DB to Redis.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * CSRF defense-in-depth — Origin verification for state-changing requests.
3
+ *
4
+ * With cross-origin cookie deployments (sameSite=none), browsers attach auth
5
+ * cookies to cross-site requests. CORS only protects *reading* responses — it
6
+ * does not stop the side effects of a forged POST/PUT/PATCH/DELETE. This check
7
+ * rejects browser-originated state-changing requests whose Origin header is
8
+ * neither the API itself (same-origin) nor a configured CORS origin.
9
+ *
10
+ * Deliberately conservative:
11
+ * - No Origin header → ALLOWED. Non-browser clients (curl, Postman,
12
+ * server-to-server) omit it, and they carry no ambient cookies, so they are
13
+ * not CSRF vectors. Browsers always send Origin on cross-site state-changing
14
+ * requests, which is the only case this defends against.
15
+ * - allowAllOrigins (development CORS) → ALLOWED.
16
+ */
17
+ export function isOriginAllowed(
18
+ origin: string | undefined,
19
+ requestHost: string | undefined,
20
+ allowedOrigins: ReadonlySet<string>,
21
+ allowAllOrigins: boolean,
22
+ ): boolean {
23
+ if (!origin) return true; // non-browser client — not a CSRF vector
24
+ if (allowAllOrigins) return true; // development: CORS allows all origins
25
+ if (allowedOrigins.has(origin)) return true; // configured cross-origin client
26
+
27
+ // Same-origin request: the Origin's host matches the request's Host header.
28
+ // Compared scheme-insensitively — TLS usually terminates at the reverse
29
+ // proxy, so the API may see the request as http while Origin says https.
30
+ // The Host header is lowercased before comparing: `new URL()` normalizes the
31
+ // Origin's host to lowercase, but the raw Host header arrives as-sent and
32
+ // host names are case-insensitive (RFC 9110).
33
+ try {
34
+ return requestHost !== undefined && new URL(origin).host === requestHost.toLowerCase();
35
+ } catch {
36
+ return false; // malformed Origin header — reject
37
+ }
38
+ }
@@ -10,7 +10,7 @@ export function getRedis(): Redis {
10
10
  maxRetriesPerRequest: env.REDIS_MAX_RETRIES,
11
11
  connectTimeout: env.REDIS_CONNECT_TIMEOUT,
12
12
  lazyConnect: true,
13
- retryStrategy: (times: number) => {
13
+ retryStrategy: (times: number): number | null => {
14
14
  // Exponential backoff with max delay of 3 seconds
15
15
  if (times > env.REDIS_MAX_RETRIES) {
16
16
  // Stop retrying after max retries
@@ -3,8 +3,8 @@ import * as authService from '../auth.service.js';
3
3
  import * as authRepo from '../auth.repo.js';
4
4
  import * as authLib from '@libs/auth.js';
5
5
  import { hashPassword, verifyPassword } from '@libs/password.js';
6
- import { ConflictError, UnauthorizedError, NotFoundError } from '@shared/errors/errors.js';
7
- import { testUsers, testRefreshToken, resetMocks } from '@/test/setup.js';
6
+ import { ConflictError, UnauthorizedError, ForbiddenError, NotFoundError } from '@shared/errors/errors.js';
7
+ import { testUsers, testRefreshToken, testSession, resetMocks } from '@/test/setup.js';
8
8
  import { sessionRepository } from '../session.repo.js';
9
9
 
10
10
  // Mock dependencies
@@ -13,10 +13,31 @@ vi.mock('@libs/auth.js');
13
13
  vi.mock('@libs/password.js');
14
14
  vi.mock('../session.repo.js');
15
15
 
16
+ // In-memory Redis stub — account lockout (email+IP) uses Redis. Tests must
17
+ // never require a live Redis instance.
18
+ const mockRedis = vi.hoisted(() => ({
19
+ exists: vi.fn(),
20
+ incr: vi.fn(),
21
+ expire: vi.fn(),
22
+ set: vi.fn(),
23
+ get: vi.fn(),
24
+ del: vi.fn(),
25
+ }));
26
+
27
+ vi.mock('@libs/redis.js', () => ({
28
+ getRedis: (): typeof mockRedis => mockRedis,
29
+ }));
30
+
16
31
  describe('Auth Service', () => {
17
32
  beforeEach(() => {
18
33
  resetMocks();
19
34
  vi.clearAllMocks();
35
+ // Default Redis behavior: nothing locked, first failure
36
+ mockRedis.exists.mockResolvedValue(0);
37
+ mockRedis.incr.mockResolvedValue(1);
38
+ mockRedis.expire.mockResolvedValue(1);
39
+ mockRedis.set.mockResolvedValue('OK');
40
+ mockRedis.del.mockResolvedValue(1);
20
41
  });
21
42
 
22
43
  describe('register', () => {
@@ -28,7 +49,8 @@ describe('Auth Service', () => {
28
49
  };
29
50
 
30
51
  it('should successfully register a new user', async () => {
31
- // Arrange
52
+ // Arrange — REQUIRE_USER_VERIFICATION=false in tests, so the user is
53
+ // active immediately and tokens are issued.
32
54
  const hashedPassword = '$2a$12$hashedpassword';
33
55
  const createdUser = {
34
56
  ...testUsers.validUser,
@@ -40,11 +62,13 @@ describe('Auth Service', () => {
40
62
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
41
63
 
42
64
  vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
65
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
43
66
  vi.mocked(hashPassword).mockResolvedValue(hashedPassword);
44
67
  vi.mocked(authRepo.createUser).mockResolvedValue(createdUser);
45
68
  vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
46
69
  vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
47
70
  vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
71
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
48
72
  vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
49
73
 
50
74
  // Act
@@ -58,15 +82,23 @@ describe('Auth Service', () => {
58
82
  password: hashedPassword,
59
83
  firstName: validRegisterInput.firstName,
60
84
  lastName: validRegisterInput.lastName,
85
+ isActive: true,
61
86
  });
62
87
  expect(authLib.signAccessToken).toHaveBeenCalledWith({
63
88
  userId: createdUser.id,
64
89
  role: createdUser.role,
65
90
  });
66
91
  expect(authLib.generateRefreshToken).toHaveBeenCalled();
92
+ expect(sessionRepository.createSession).toHaveBeenCalledWith({
93
+ userId: createdUser.id,
94
+ deviceInfo: undefined,
95
+ ipAddress: undefined,
96
+ expiresAt,
97
+ });
67
98
  expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
68
99
  token: refreshToken,
69
100
  userId: createdUser.id,
101
+ sessionId: testSession.id,
70
102
  expiresAt,
71
103
  });
72
104
  expect(result).toEqual({
@@ -79,6 +111,7 @@ describe('Auth Service', () => {
79
111
  }),
80
112
  accessToken,
81
113
  refreshToken,
114
+ requiresVerification: false,
82
115
  });
83
116
  expect(result.user).not.toHaveProperty('password');
84
117
  });
@@ -93,6 +126,19 @@ describe('Auth Service', () => {
93
126
  expect(hashPassword).not.toHaveBeenCalled();
94
127
  expect(authRepo.createUser).not.toHaveBeenCalled();
95
128
  });
129
+
130
+ it('should throw ConflictError if a soft-deleted account exists with this email', async () => {
131
+ // Arrange
132
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
133
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue({
134
+ ...testUsers.validUser,
135
+ deletedAt: new Date(),
136
+ });
137
+
138
+ // Act & Assert
139
+ await expect(authService.register(validRegisterInput)).rejects.toThrow(ConflictError);
140
+ expect(authRepo.createUser).not.toHaveBeenCalled();
141
+ });
96
142
  });
97
143
 
98
144
  describe('login', () => {
@@ -112,6 +158,7 @@ describe('Auth Service', () => {
112
158
  vi.mocked(authLib.signAccessToken).mockReturnValue(accessToken);
113
159
  vi.mocked(authLib.generateRefreshToken).mockReturnValue(refreshToken);
114
160
  vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
161
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
115
162
  vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
116
163
 
117
164
  // Act
@@ -120,6 +167,18 @@ describe('Auth Service', () => {
120
167
  // Assert
121
168
  expect(authRepo.findUserByEmail).toHaveBeenCalledWith(validLoginInput.email);
122
169
  expect(verifyPassword).toHaveBeenCalledWith(validLoginInput.password, testUsers.validUser.password);
170
+ expect(sessionRepository.createSession).toHaveBeenCalledWith({
171
+ userId: testUsers.validUser.id,
172
+ deviceInfo: undefined,
173
+ ipAddress: undefined,
174
+ expiresAt,
175
+ });
176
+ expect(authRepo.createRefreshToken).toHaveBeenCalledWith({
177
+ token: refreshToken,
178
+ userId: testUsers.validUser.id,
179
+ sessionId: testSession.id,
180
+ expiresAt,
181
+ });
123
182
  expect(result).toEqual({
124
183
  user: expect.objectContaining({
125
184
  id: testUsers.validUser.id,
@@ -134,6 +193,7 @@ describe('Auth Service', () => {
134
193
  it('should throw UnauthorizedError if user not found', async () => {
135
194
  // Arrange
136
195
  vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
196
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(null);
137
197
 
138
198
  // Act & Assert
139
199
  await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
@@ -141,13 +201,15 @@ describe('Auth Service', () => {
141
201
  expect(verifyPassword).not.toHaveBeenCalled();
142
202
  });
143
203
 
144
- it('should throw UnauthorizedError if account is disabled', async () => {
204
+ it('should throw ForbiddenError if account is not activated', async () => {
145
205
  // Arrange
146
206
  vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.inactiveUser);
147
207
 
148
208
  // Act & Assert
149
- await expect(authService.login(validLoginInput)).rejects.toThrow(UnauthorizedError);
150
- await expect(authService.login(validLoginInput)).rejects.toThrow('Invalid email or password');
209
+ await expect(authService.login(validLoginInput)).rejects.toThrow(ForbiddenError);
210
+ await expect(authService.login(validLoginInput)).rejects.toThrow(
211
+ 'Account is not activated. Please verify your account.',
212
+ );
151
213
  expect(verifyPassword).not.toHaveBeenCalled();
152
214
  });
153
215
 
@@ -162,6 +224,147 @@ describe('Auth Service', () => {
162
224
  });
163
225
  });
164
226
 
227
+ describe('login — account lockout (email+IP scoped)', () => {
228
+ const loginInput = { email: 'test@example.com', password: 'WrongPassword!' };
229
+ const attackerIp = '203.0.113.7';
230
+
231
+ it('should reject login when the email+IP pair is locked, without verifying the password', async () => {
232
+ // Arrange
233
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
234
+ mockRedis.exists.mockResolvedValue(1); // lock key present for this pair
235
+
236
+ // Act & Assert
237
+ await expect(
238
+ authService.login(loginInput, 'test-agent', attackerIp),
239
+ ).rejects.toThrow('Invalid email or password');
240
+ expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${attackerIp}`);
241
+ expect(verifyPassword).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('should NOT lock out the same email from a different IP (no remote lockout DoS)', async () => {
245
+ // Arrange — lock exists only for the attacker's IP; victim logs in from
246
+ // their own IP and Redis reports no lock for that pair.
247
+ const victimIp = '198.51.100.42';
248
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
249
+
250
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
251
+ mockRedis.exists.mockResolvedValue(0);
252
+ vi.mocked(verifyPassword).mockResolvedValue(true);
253
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
254
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
255
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
256
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
257
+ vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
258
+
259
+ // Act
260
+ const result = await authService.login(
261
+ { ...loginInput, password: 'Password123!' },
262
+ 'test-agent',
263
+ victimIp,
264
+ );
265
+
266
+ // Assert — lock was checked for the VICTIM's pair only, and login succeeded
267
+ expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${victimIp}`);
268
+ expect(result.accessToken).toBe('access');
269
+ });
270
+
271
+ it('should record a failed attempt against the email+IP pair on bad password', async () => {
272
+ // Arrange
273
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
274
+ vi.mocked(verifyPassword).mockResolvedValue(false);
275
+ mockRedis.incr.mockResolvedValue(3); // below the first lockout threshold
276
+
277
+ // Act & Assert
278
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
279
+ UnauthorizedError,
280
+ );
281
+ expect(mockRedis.incr).toHaveBeenCalledWith(`login-fail:test@example.com:${attackerIp}`);
282
+ expect(mockRedis.set).not.toHaveBeenCalled(); // no lock yet
283
+ });
284
+
285
+ it('should set a lock for the email+IP pair after 5 failures (15 min)', async () => {
286
+ // Arrange
287
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
288
+ vi.mocked(verifyPassword).mockResolvedValue(false);
289
+ mockRedis.incr.mockResolvedValue(5); // hits the first threshold
290
+
291
+ // Act & Assert
292
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
293
+ UnauthorizedError,
294
+ );
295
+ expect(mockRedis.set).toHaveBeenCalledWith(
296
+ `login-lock:test@example.com:${attackerIp}`,
297
+ '1',
298
+ 'EX',
299
+ 15 * 60,
300
+ );
301
+ });
302
+
303
+ it('should fail open and allow login when Redis is unavailable', async () => {
304
+ // Arrange — Redis down: lockout checks must not block all logins
305
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
306
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(testUsers.validUser);
307
+ mockRedis.exists.mockRejectedValue(new Error('Redis down'));
308
+ mockRedis.del.mockRejectedValue(new Error('Redis down'));
309
+ vi.mocked(verifyPassword).mockResolvedValue(true);
310
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
311
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
312
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(expiresAt);
313
+ vi.mocked(sessionRepository.createSession).mockResolvedValue(testSession);
314
+ vi.mocked(authRepo.createRefreshToken).mockResolvedValue(testRefreshToken);
315
+
316
+ // Act
317
+ const result = await authService.login(
318
+ { ...loginInput, password: 'Password123!' },
319
+ 'test-agent',
320
+ attackerIp,
321
+ );
322
+
323
+ // Assert
324
+ expect(result.accessToken).toBe('access');
325
+ });
326
+ });
327
+
328
+ describe('login — soft-deleted account restore path (lockout)', () => {
329
+ const loginInput = { email: 'test@example.com', password: 'WrongPassword!' };
330
+ const attackerIp = '203.0.113.7';
331
+ const softDeletedUser = {
332
+ ...testUsers.validUser,
333
+ deletedAt: new Date('2024-02-01T00:00:00Z'),
334
+ isActive: false,
335
+ };
336
+
337
+ it('should record a failed attempt against the email+IP pair on wrong password for a soft-deleted account', async () => {
338
+ // Arrange
339
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
340
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(softDeletedUser);
341
+ vi.mocked(verifyPassword).mockResolvedValue(false);
342
+ mockRedis.incr.mockResolvedValue(3); // below the first lockout threshold
343
+
344
+ // Act & Assert
345
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
346
+ 'Invalid email or password',
347
+ );
348
+ expect(mockRedis.incr).toHaveBeenCalledWith(`login-fail:test@example.com:${attackerIp}`);
349
+ expect(authRepo.restoreUser).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('should reject a locked email+IP pair on the soft-delete path before verifying the password', async () => {
353
+ // Arrange
354
+ vi.mocked(authRepo.findUserByEmail).mockResolvedValue(null);
355
+ vi.mocked(authRepo.findDeletedUserByEmail).mockResolvedValue(softDeletedUser);
356
+ mockRedis.exists.mockResolvedValue(1); // lock key present for this pair
357
+
358
+ // Act & Assert
359
+ await expect(authService.login(loginInput, 'test-agent', attackerIp)).rejects.toThrow(
360
+ 'Invalid email or password',
361
+ );
362
+ expect(mockRedis.exists).toHaveBeenCalledWith(`login-lock:test@example.com:${attackerIp}`);
363
+ expect(verifyPassword).not.toHaveBeenCalled();
364
+ expect(authRepo.restoreUser).not.toHaveBeenCalled();
365
+ });
366
+ });
367
+
165
368
  describe('refresh', () => {
166
369
  const validRefreshToken = 'valid-refresh-token';
167
370
 
@@ -196,6 +399,7 @@ describe('Auth Service', () => {
196
399
  expect(authRepo.rotateRefreshToken).toHaveBeenCalledWith(validRefreshToken, {
197
400
  token: newRefreshToken,
198
401
  userId: testUsers.validUser.id,
402
+ sessionId: undefined, // fixture sessionId is null → normalized to undefined
199
403
  expiresAt,
200
404
  });
201
405
  expect(result).toEqual({
@@ -242,7 +446,7 @@ describe('Auth Service', () => {
242
446
  await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
243
447
  });
244
448
 
245
- it('should throw UnauthorizedError if user is disabled', async () => {
449
+ it('should throw ForbiddenError if user is not activated', async () => {
246
450
  // Arrange
247
451
  const storedToken = {
248
452
  ...testRefreshToken,
@@ -251,35 +455,73 @@ describe('Auth Service', () => {
251
455
  vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
252
456
  vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.inactiveUser);
253
457
 
458
+ // Act & Assert
459
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(ForbiddenError);
460
+ await expect(authService.refresh(validRefreshToken)).rejects.toThrow(
461
+ 'Account is not activated. Please verify your account.',
462
+ );
463
+ });
464
+
465
+ it('should revoke ALL refresh tokens and sessions when token reuse is detected', async () => {
466
+ // Regression test for refresh-token-reuse family revocation:
467
+ // the token was found, but the atomic rotation reports it was already
468
+ // consumed by a concurrent request — two parties presented the same
469
+ // single-use token. The whole family must be revoked, because whichever
470
+ // party redeemed it first (possibly an attacker) holds a valid token.
471
+ const storedToken = {
472
+ ...testRefreshToken,
473
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
474
+ };
475
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
476
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
477
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
478
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
479
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(new Date());
480
+ vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(false); // already consumed
481
+ vi.mocked(authRepo.deleteRefreshTokensByUserId).mockResolvedValue(undefined);
482
+ vi.mocked(sessionRepository.deleteAllUserSessions).mockResolvedValue(1);
483
+
254
484
  // Act & Assert
255
485
  await expect(authService.refresh(validRefreshToken)).rejects.toThrow(UnauthorizedError);
256
- await expect(authService.refresh(validRefreshToken)).rejects.toThrow('User not found or disabled');
486
+ expect(authRepo.deleteRefreshTokensByUserId).toHaveBeenCalledWith(testUsers.validUser.id);
487
+ expect(sessionRepository.deleteAllUserSessions).toHaveBeenCalledWith(testUsers.validUser.id);
488
+ });
489
+
490
+ it('should NOT revoke the token family on a successful rotation', async () => {
491
+ // Companion to the reuse test — the revocation path must not fire on
492
+ // the happy path.
493
+ const storedToken = {
494
+ ...testRefreshToken,
495
+ expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
496
+ };
497
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
498
+ vi.mocked(authRepo.findUserById).mockResolvedValue(testUsers.validUser);
499
+ vi.mocked(authLib.signAccessToken).mockReturnValue('access');
500
+ vi.mocked(authLib.generateRefreshToken).mockReturnValue('refresh');
501
+ vi.mocked(authLib.getRefreshTokenExpiresAt).mockReturnValue(new Date());
502
+ vi.mocked(authRepo.rotateRefreshToken).mockResolvedValue(true);
503
+
504
+ // Act
505
+ await authService.refresh(validRefreshToken);
506
+
507
+ // Assert
508
+ expect(authRepo.deleteRefreshTokensByUserId).not.toHaveBeenCalled();
509
+ expect(sessionRepository.deleteAllUserSessions).not.toHaveBeenCalled();
257
510
  });
258
511
  });
259
512
 
260
513
  describe('logout', () => {
261
- it('should delete refresh token and matching session', async () => {
514
+ it('should delete refresh token and its linked session', async () => {
262
515
  // Arrange
263
516
  const refreshToken = 'valid-refresh-token';
264
- const tokenCreatedAt = new Date('2024-06-01T12:00:00Z');
265
517
  const storedToken = {
266
518
  ...testRefreshToken,
267
519
  token: refreshToken,
268
- createdAt: tokenCreatedAt,
269
- };
270
- const matchingSession = {
271
- id: 'session-1',
272
- userId: testUsers.validUser.id,
273
- deviceInfo: 'test-agent',
274
- ipAddress: '127.0.0.1',
275
- lastActiveAt: new Date(),
276
- expiresAt: new Date(Date.now() + 86400000),
277
- createdAt: new Date('2024-06-01T12:00:00.100Z'), // within 5s of token
520
+ sessionId: 'session-1',
278
521
  };
279
522
 
280
523
  vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
281
524
  vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
282
- vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([matchingSession]);
283
525
  vi.mocked(sessionRepository.deleteSession).mockResolvedValue(undefined as never);
284
526
 
285
527
  // Act
@@ -288,46 +530,34 @@ describe('Auth Service', () => {
288
530
  // Assert
289
531
  expect(authRepo.findRefreshToken).toHaveBeenCalledWith(refreshToken);
290
532
  expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
291
- expect(sessionRepository.getUserSessions).toHaveBeenCalledWith(storedToken.userId);
292
- expect(sessionRepository.deleteSession).toHaveBeenCalledWith(matchingSession.id);
533
+ expect(sessionRepository.deleteSession).toHaveBeenCalledWith('session-1');
293
534
  });
294
535
 
295
- it('should not delete session if no matching createdAt found', async () => {
296
- // Arrange
536
+ it('should not delete a session when the token has no sessionId', async () => {
537
+ // Arrange — testRefreshToken fixture has sessionId: null
297
538
  const refreshToken = 'valid-refresh-token';
298
- const storedToken = {
539
+ vi.mocked(authRepo.findRefreshToken).mockResolvedValue({
299
540
  ...testRefreshToken,
300
541
  token: refreshToken,
301
- createdAt: new Date('2024-06-01T12:00:00Z'),
302
- };
303
- const unmatchedSession = {
304
- id: 'session-2',
305
- userId: testUsers.validUser.id,
306
- deviceInfo: null,
307
- ipAddress: null,
308
- lastActiveAt: new Date(),
309
- expiresAt: new Date(Date.now() + 86400000),
310
- createdAt: new Date('2024-05-01T00:00:00Z'), // way off — no match
311
- };
312
-
313
- vi.mocked(authRepo.findRefreshToken).mockResolvedValue(storedToken);
542
+ });
314
543
  vi.mocked(authRepo.deleteRefreshToken).mockResolvedValue(undefined);
315
- vi.mocked(sessionRepository.getUserSessions).mockResolvedValue([unmatchedSession]);
316
544
 
317
545
  // Act
318
546
  await authService.logout(refreshToken);
319
547
 
320
548
  // Assert
549
+ expect(authRepo.deleteRefreshToken).toHaveBeenCalledWith(refreshToken);
321
550
  expect(sessionRepository.deleteSession).not.toHaveBeenCalled();
322
551
  });
323
552
 
324
- it('should not throw error if token deletion fails', async () => {
325
- // Arrange
326
- const refreshToken = 'invalid-refresh-token';
553
+ it('should propagate unexpected repository errors', async () => {
554
+ // "Token already deleted" is handled gracefully inside the repo (P2025);
555
+ // anything else is a real failure and must reach the global error handler.
556
+ const refreshToken = 'valid-refresh-token';
327
557
  vi.mocked(authRepo.findRefreshToken).mockRejectedValue(new Error('DB error'));
328
558
 
329
559
  // Act & Assert
330
- await expect(authService.logout(refreshToken)).resolves.not.toThrow();
560
+ await expect(authService.logout(refreshToken)).rejects.toThrow('DB error');
331
561
  });
332
562
  });
333
563
 
@@ -93,6 +93,7 @@ export async function deleteRefreshTokensByUserId(userId: string): Promise<void>
93
93
  });
94
94
  }
95
95
 
96
+ /** @deprecated Lockout moved to Redis email+IP keys in auth.service.ts; kept for back-compat, slated for removal. */
96
97
  export async function incrementFailedAttempts(userId: string): Promise<void> {
97
98
  await prisma.user.update({
98
99
  where: { id: userId },
@@ -102,6 +103,7 @@ export async function incrementFailedAttempts(userId: string): Promise<void> {
102
103
  });
103
104
  }
104
105
 
106
+ /** @deprecated Lockout moved to Redis email+IP keys in auth.service.ts; kept for back-compat, slated for removal. */
105
107
  export async function setAccountLock(userId: string, lockedUntil: Date): Promise<void> {
106
108
  await prisma.user.update({
107
109
  where: { id: userId },