create-tigra 2.8.0 → 3.0.2
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/README.md +10 -3
- package/bin/create-tigra.js +77 -37
- package/package.json +5 -5
- package/template/_claude/commands/create-server.md +8 -2
- package/template/_claude/rules/client/01-project-structure.md +12 -0
- package/template/_claude/rules/client/03-data-and-state.md +1 -1
- package/template/_claude/rules/client/04-design-system.md +23 -0
- package/template/_claude/rules/client/07-deployment.md +99 -0
- package/template/_claude/rules/client/08-lockfile-cross-platform.md +79 -0
- package/template/_claude/rules/client/core.md +1 -0
- package/template/_claude/rules/global/core.md +20 -1
- package/template/_claude/rules/global/investigation-before-conclusions.md +57 -0
- package/template/_claude/rules/server/core.md +2 -0
- package/template/_claude/rules/server/deployment.md +78 -0
- package/template/client/next.config.ts +12 -2
- package/template/client/package-lock.json +12345 -0
- package/template/client/package.json +3 -2
- package/template/client/src/components/common/SafeImage.tsx +2 -1
- package/template/client/src/lib/api/axios.config.ts +19 -4
- package/template/client/src/middleware.ts +7 -0
- package/template/gitignore +1 -0
- package/template/server/.env.example +248 -194
- package/template/server/.env.example.production +221 -168
- package/template/server/Dockerfile +29 -5
- package/template/server/docker-compose.yml +32 -4
- package/template/server/package-lock.json +6544 -6823
- package/template/server/package.json +76 -75
- package/template/server/prisma/seed.ts +20 -4
- package/template/server/src/app.ts +316 -271
- package/template/server/src/config/env.ts +150 -99
- package/template/server/src/config/rate-limit.config.ts +16 -0
- package/template/server/src/libs/__tests__/auth-path.test.ts +24 -0
- package/template/server/src/libs/__tests__/client-ip.test.ts +121 -0
- package/template/server/src/libs/__tests__/http.test.ts +23 -9
- package/template/server/src/libs/__tests__/ip-block.test.ts +62 -0
- package/template/server/src/libs/__tests__/origin-check.test.ts +53 -0
- package/template/server/src/libs/__tests__/url-safety.test.ts +80 -0
- package/template/server/src/libs/auth-path.ts +14 -0
- package/template/server/src/libs/auth.ts +6 -16
- package/template/server/src/libs/client-ip.ts +77 -0
- package/template/server/src/libs/cookies.ts +1 -1
- package/template/server/src/libs/duration.ts +30 -0
- package/template/server/src/libs/ip-block.ts +220 -206
- package/template/server/src/libs/origin-check.ts +38 -0
- package/template/server/src/libs/query-counter.ts +59 -0
- package/template/server/src/libs/redis.ts +1 -1
- package/template/server/src/libs/url-safety.ts +121 -0
- package/template/server/src/modules/auth/__tests__/auth.service.test.ts +274 -44
- package/template/server/src/modules/auth/auth.controller.ts +128 -127
- package/template/server/src/modules/auth/auth.repo.ts +2 -0
- package/template/server/src/modules/auth/auth.service.ts +103 -12
- package/template/server/src/test/setup.ts +22 -2
- package/template/server/vitest.config.ts +43 -43
|
@@ -1,127 +1,128 @@
|
|
|
1
|
-
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
-
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
-
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
4
|
-
import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
reply
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
reply.
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
reply.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
reply
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
reply
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import { successResponse } from '@shared/responses/successResponse.js';
|
|
3
|
+
import { UnauthorizedError, ForbiddenError } from '@shared/errors/errors.js';
|
|
4
|
+
import { setAuthCookies, clearAuthCookies } from '@libs/cookies.js';
|
|
5
|
+
import { getClientIp } from '@libs/client-ip.js';
|
|
6
|
+
import type {
|
|
7
|
+
RegisterInput,
|
|
8
|
+
LoginInput,
|
|
9
|
+
ForgotPasswordInput,
|
|
10
|
+
ResetPasswordInput,
|
|
11
|
+
} from './auth.schemas.js';
|
|
12
|
+
import * as authService from './auth.service.js';
|
|
13
|
+
|
|
14
|
+
export async function register(
|
|
15
|
+
request: FastifyRequest<{ Body: RegisterInput }>,
|
|
16
|
+
reply: FastifyReply,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const deviceInfo = request.headers['user-agent'];
|
|
19
|
+
const ipAddress = getClientIp(request);
|
|
20
|
+
|
|
21
|
+
const result = await authService.register(request.body, deviceInfo, ipAddress);
|
|
22
|
+
|
|
23
|
+
if (result.requiresVerification) {
|
|
24
|
+
reply.status(201).send(
|
|
25
|
+
successResponse('Registration successful. Please verify your account to continue.', { user: result.user }),
|
|
26
|
+
);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setAuthCookies(reply, result.accessToken!, result.refreshToken!);
|
|
31
|
+
reply.status(201).send(successResponse('User registered successfully', { user: result.user }));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function login(
|
|
35
|
+
request: FastifyRequest<{ Body: LoginInput }>,
|
|
36
|
+
reply: FastifyReply,
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const deviceInfo = request.headers['user-agent'];
|
|
39
|
+
const ipAddress = getClientIp(request);
|
|
40
|
+
|
|
41
|
+
const result = await authService.login(request.body, deviceInfo, ipAddress);
|
|
42
|
+
|
|
43
|
+
setAuthCookies(reply, result.accessToken, result.refreshToken);
|
|
44
|
+
reply.send(successResponse('Logged in successfully', { user: result.user }));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function refresh(
|
|
48
|
+
request: FastifyRequest,
|
|
49
|
+
reply: FastifyReply,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const refreshToken = request.cookies.refresh_token;
|
|
52
|
+
if (!refreshToken) {
|
|
53
|
+
clearAuthCookies(reply);
|
|
54
|
+
throw new UnauthorizedError('Refresh token not provided', 'MISSING_REFRESH_TOKEN');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const tokens = await authService.refresh(refreshToken);
|
|
59
|
+
setAuthCookies(reply, tokens.accessToken, tokens.refreshToken);
|
|
60
|
+
reply.send(successResponse('Token refreshed successfully', null));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Session is definitively dead (refresh token revoked, user gone, inactive).
|
|
63
|
+
// Clear all auth cookies so the browser stops replaying them — otherwise the
|
|
64
|
+
// client keeps retrying refresh and middleware keeps redirecting, producing
|
|
65
|
+
// an infinite loop that trips the rate limiter.
|
|
66
|
+
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
|
67
|
+
clearAuthCookies(reply);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function logout(
|
|
74
|
+
request: FastifyRequest,
|
|
75
|
+
reply: FastifyReply,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const refreshToken = request.cookies.refresh_token;
|
|
78
|
+
if (refreshToken) {
|
|
79
|
+
await authService.logout(refreshToken);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
clearAuthCookies(reply);
|
|
83
|
+
reply.send(successResponse('Logged out successfully', null));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function me(
|
|
87
|
+
request: FastifyRequest,
|
|
88
|
+
reply: FastifyReply,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const user = await authService.getCurrentUser(request.user.userId);
|
|
91
|
+
reply.send(successResponse('Current user retrieved', user));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function getSessions(
|
|
95
|
+
request: FastifyRequest,
|
|
96
|
+
reply: FastifyReply,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const sessions = await authService.getUserSessions(request.user.userId);
|
|
99
|
+
reply.send(successResponse('User sessions retrieved', sessions));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function logoutAllSessions(
|
|
103
|
+
request: FastifyRequest,
|
|
104
|
+
reply: FastifyReply,
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
const count = await authService.logoutAllSessions(request.user.userId);
|
|
107
|
+
clearAuthCookies(reply);
|
|
108
|
+
reply.send(
|
|
109
|
+
successResponse(`Successfully logged out from ${count} session(s)`, { count }),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function forgotPassword(
|
|
114
|
+
request: FastifyRequest<{ Body: ForgotPasswordInput }>,
|
|
115
|
+
reply: FastifyReply,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
await authService.forgotPassword(request.body.email);
|
|
118
|
+
// Always return success to prevent email enumeration
|
|
119
|
+
reply.send(successResponse('If an account exists with that email, a reset link has been sent', null));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function resetPassword(
|
|
123
|
+
request: FastifyRequest<{ Body: ResetPasswordInput }>,
|
|
124
|
+
reply: FastifyReply,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await authService.resetPassword(request.body.token, request.body.newPassword);
|
|
127
|
+
reply.send(successResponse('Password reset successfully', null));
|
|
128
|
+
}
|
|
@@ -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 },
|
|
@@ -21,6 +21,18 @@ const PASSWORD_RESET_TTL = 3600; // 1 hour in seconds
|
|
|
21
21
|
const PASSWORD_RESET_PREFIX = 'pw-reset:';
|
|
22
22
|
const PASSWORD_RESET_USER_PREFIX = 'pw-reset-user:';
|
|
23
23
|
|
|
24
|
+
// --- Account lockout (scoped to email + IP) ---
|
|
25
|
+
// Failed-login attempts are tracked in Redis keyed by the (email, IP) PAIR.
|
|
26
|
+
// Keying by email alone let anyone lock a victim out remotely by spamming bad
|
|
27
|
+
// passwords at the victim's email (lockout DoS). Scoped to email+IP, an
|
|
28
|
+
// attacker only ever locks out their own address; distributed attempts are
|
|
29
|
+
// still throttled by the per-IP rate limit on the login route.
|
|
30
|
+
// Redis failures fail OPEN (no lockout) — consistent with ip-block.ts:
|
|
31
|
+
// availability over lockout, and rate limiting still applies.
|
|
32
|
+
const LOGIN_FAIL_PREFIX = 'login-fail:';
|
|
33
|
+
const LOGIN_LOCK_PREFIX = 'login-lock:';
|
|
34
|
+
const LOGIN_FAIL_WINDOW_SECONDS = 60 * 60; // failed attempts expire after 1 hour
|
|
35
|
+
|
|
24
36
|
// Account lockout configuration
|
|
25
37
|
const LOCKOUT_THRESHOLDS = [
|
|
26
38
|
{ attempts: 5, durationMs: 15 * 60 * 1000 }, // 5 failures → 15 min
|
|
@@ -37,6 +49,55 @@ function getLockoutDuration(failedAttempts: number): number | null {
|
|
|
37
49
|
return null;
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
function lockoutKey(prefix: string, email: string, ipAddress?: string): string {
|
|
53
|
+
return `${prefix}${email.toLowerCase()}:${ipAddress ?? 'unknown'}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function isLoginLocked(email: string, ipAddress?: string): Promise<boolean> {
|
|
57
|
+
try {
|
|
58
|
+
const locked = await getRedis().exists(lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress));
|
|
59
|
+
return locked === 1;
|
|
60
|
+
} catch {
|
|
61
|
+
return false; // fail open — Redis down must not block all logins
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function recordFailedLogin(email: string, ipAddress?: string): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
const redis = getRedis();
|
|
68
|
+
const failKey = lockoutKey(LOGIN_FAIL_PREFIX, email, ipAddress);
|
|
69
|
+
|
|
70
|
+
const attempts = await redis.incr(failKey);
|
|
71
|
+
if (attempts === 1) {
|
|
72
|
+
await redis.expire(failKey, LOGIN_FAIL_WINDOW_SECONDS);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lockDurationMs = getLockoutDuration(attempts);
|
|
76
|
+
if (lockDurationMs) {
|
|
77
|
+
await redis.set(
|
|
78
|
+
lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress),
|
|
79
|
+
'1',
|
|
80
|
+
'EX',
|
|
81
|
+
Math.ceil(lockDurationMs / 1000),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Non-critical: don't break the login error path if tracking fails
|
|
86
|
+
logger.warn('[AUTH] Failed to record failed login attempt');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function clearFailedLogins(email: string, ipAddress?: string): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
await getRedis().del(
|
|
93
|
+
lockoutKey(LOGIN_FAIL_PREFIX, email, ipAddress),
|
|
94
|
+
lockoutKey(LOGIN_LOCK_PREFIX, email, ipAddress),
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
// Non-critical: keys expire on their own
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
40
101
|
interface SanitizedUser {
|
|
41
102
|
id: string;
|
|
42
103
|
email: string;
|
|
@@ -167,12 +228,23 @@ export async function login(
|
|
|
167
228
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
168
229
|
}
|
|
169
230
|
|
|
231
|
+
// Same email+IP lockout as the normal path — the restore flow must not be
|
|
232
|
+
// a brute-force side door around the lockout.
|
|
233
|
+
if (await isLoginLocked(input.email, ipAddress)) {
|
|
234
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
235
|
+
}
|
|
236
|
+
|
|
170
237
|
// Verify password before restoring — don't restore on wrong password
|
|
171
238
|
const validPassword = await verifyPassword(input.password, deletedUser.password);
|
|
172
239
|
if (!validPassword) {
|
|
240
|
+
await recordFailedLogin(input.email, ipAddress);
|
|
173
241
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
174
242
|
}
|
|
175
243
|
|
|
244
|
+
// Successful restore-login — clear the email+IP failure counter, same as
|
|
245
|
+
// the normal path.
|
|
246
|
+
await clearFailedLogins(input.email, ipAddress);
|
|
247
|
+
|
|
176
248
|
// Restore account: clears deletedAt, sets isActive = true, resets lockout
|
|
177
249
|
await authRepo.restoreUser(deletedUser.id);
|
|
178
250
|
user = { ...deletedUser, deletedAt: null, isActive: true, failedLoginAttempts: 0, lockedUntil: null };
|
|
@@ -184,7 +256,14 @@ export async function login(
|
|
|
184
256
|
throw new ForbiddenError('Account is not activated. Please verify your account.', 'ACCOUNT_NOT_ACTIVE');
|
|
185
257
|
}
|
|
186
258
|
|
|
187
|
-
// Check account lockout
|
|
259
|
+
// Check account lockout — scoped to this email+IP pair (Redis), so an
|
|
260
|
+
// attacker spamming bad passwords only locks out their OWN address and
|
|
261
|
+
// cannot remotely lock the real user out (lockout DoS).
|
|
262
|
+
if (await isLoginLocked(input.email, ipAddress)) {
|
|
263
|
+
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// DB-level lock (legacy data or manual admin lock) is still honored.
|
|
188
267
|
if (user.lockedUntil && user.lockedUntil > new Date()) {
|
|
189
268
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
190
269
|
}
|
|
@@ -192,20 +271,13 @@ export async function login(
|
|
|
192
271
|
const valid = await verifyPassword(input.password, user.password);
|
|
193
272
|
|
|
194
273
|
if (!valid) {
|
|
195
|
-
|
|
196
|
-
const newAttempts = user.failedLoginAttempts + 1;
|
|
197
|
-
await authRepo.incrementFailedAttempts(user.id);
|
|
198
|
-
|
|
199
|
-
// Check if we need to lock the account
|
|
200
|
-
const lockDuration = getLockoutDuration(newAttempts);
|
|
201
|
-
if (lockDuration) {
|
|
202
|
-
await authRepo.setAccountLock(user.id, new Date(Date.now() + lockDuration));
|
|
203
|
-
}
|
|
204
|
-
|
|
274
|
+
await recordFailedLogin(input.email, ipAddress);
|
|
205
275
|
throw new UnauthorizedError('Invalid email or password', 'INVALID_CREDENTIALS');
|
|
206
276
|
}
|
|
207
277
|
|
|
208
|
-
// Successful login —
|
|
278
|
+
// Successful login — clear the email+IP failure counter and any stale
|
|
279
|
+
// DB-level lockout state from before lockout moved to Redis.
|
|
280
|
+
await clearFailedLogins(input.email, ipAddress);
|
|
209
281
|
if (user.failedLoginAttempts > 0 || user.lockedUntil) {
|
|
210
282
|
await authRepo.resetFailedAttempts(user.id);
|
|
211
283
|
}
|
|
@@ -274,6 +346,25 @@ export async function refresh(refreshToken: string): Promise<{ accessToken: stri
|
|
|
274
346
|
});
|
|
275
347
|
|
|
276
348
|
if (!rotated) {
|
|
349
|
+
// REUSE DETECTED: the token existed when we looked it up but was consumed
|
|
350
|
+
// by a concurrent request before we could rotate it — two parties presented
|
|
351
|
+
// the same single-use token. We cannot tell which one is the attacker, and
|
|
352
|
+
// whichever redeemed it first already holds a freshly rotated valid token.
|
|
353
|
+
// Revoke the entire token family: every refresh token and session for this
|
|
354
|
+
// user. Both legit client and attacker must re-authenticate.
|
|
355
|
+
//
|
|
356
|
+
// NOTE: this covers the *detectable* reuse path. Refresh tokens are opaque
|
|
357
|
+
// random UUIDs (no JWT claims), so a token that is not found in the DB at
|
|
358
|
+
// all (deleted in an earlier rotation) cannot be attributed to a user, and
|
|
359
|
+
// the initial not-found lookup above can only reject it. Full replay
|
|
360
|
+
// attribution would require tombstoning consumed tokens (e.g. a revokedAt
|
|
361
|
+
// column) instead of deleting them — a schema change out of scope here.
|
|
362
|
+
logger.warn(
|
|
363
|
+
{ userId: user.id },
|
|
364
|
+
'[AUTH] Refresh token reuse detected — revoking all sessions and refresh tokens for user',
|
|
365
|
+
);
|
|
366
|
+
await authRepo.deleteRefreshTokensByUserId(user.id);
|
|
367
|
+
await sessionRepository.deleteAllUserSessions(user.id);
|
|
277
368
|
throw new UnauthorizedError('Refresh token already used', 'INVALID_REFRESH_TOKEN');
|
|
278
369
|
}
|
|
279
370
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { vi } from 'vitest';
|
|
2
2
|
|
|
3
|
-
// Mock environment variables for testing
|
|
3
|
+
// Mock environment variables for testing.
|
|
4
|
+
// This setup file runs BEFORE any test file is imported (vitest setupFiles),
|
|
5
|
+
// so these must form a COMPLETE valid env: src/config/env.ts validates on
|
|
6
|
+
// import and calls process.exit(1) on failure, killing the whole suite before
|
|
7
|
+
// a single test runs.
|
|
4
8
|
process.env.NODE_ENV = 'test';
|
|
5
9
|
process.env.DATABASE_URL = 'mysql://test:test@localhost:3306/test_db';
|
|
6
10
|
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
@@ -9,6 +13,9 @@ process.env.JWT_ACCESS_EXPIRY = '15m';
|
|
|
9
13
|
process.env.JWT_REFRESH_EXPIRY = '7d';
|
|
10
14
|
process.env.PORT = '3000';
|
|
11
15
|
process.env.HOST = '0.0.0.0';
|
|
16
|
+
// REQUIRE_USER_VERIFICATION defaults to 'true', and env.ts exits when it is
|
|
17
|
+
// enabled without RESEND_API_KEY. Tests don't send email — disable it.
|
|
18
|
+
process.env.REQUIRE_USER_VERIFICATION = 'false';
|
|
12
19
|
|
|
13
20
|
// Test user data
|
|
14
21
|
export const testUsers = {
|
|
@@ -59,15 +66,28 @@ export const testUsers = {
|
|
|
59
66
|
},
|
|
60
67
|
};
|
|
61
68
|
|
|
62
|
-
// Test refresh token data
|
|
69
|
+
// Test refresh token data — fields must match the Prisma RefreshToken model
|
|
70
|
+
// exactly (id, token, userId, sessionId, expiresAt, createdAt)
|
|
63
71
|
export const testRefreshToken = {
|
|
64
72
|
id: 'test-token-id-1',
|
|
65
73
|
token: 'test-refresh-token-uuid',
|
|
66
74
|
userId: testUsers.validUser.id,
|
|
75
|
+
sessionId: null,
|
|
67
76
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
|
68
77
|
createdAt: new Date(),
|
|
69
78
|
};
|
|
70
79
|
|
|
80
|
+
// Test session data — fields must match the Prisma Session model exactly
|
|
81
|
+
export const testSession = {
|
|
82
|
+
id: 'test-session-id-1',
|
|
83
|
+
userId: testUsers.validUser.id,
|
|
84
|
+
deviceInfo: null,
|
|
85
|
+
ipAddress: null,
|
|
86
|
+
lastActiveAt: new Date('2024-01-01T00:00:00Z'),
|
|
87
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
88
|
+
createdAt: new Date('2024-01-01T00:00:00Z'),
|
|
89
|
+
};
|
|
90
|
+
|
|
71
91
|
// Reset all mocks before each test
|
|
72
92
|
export const resetMocks = (): void => {
|
|
73
93
|
vi.clearAllMocks();
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
|
|
4
|
-
export default defineConfig({
|
|
5
|
-
test: {
|
|
6
|
-
globals: true,
|
|
7
|
-
environment: 'node',
|
|
8
|
-
coverage: {
|
|
9
|
-
provider: 'v8',
|
|
10
|
-
reporter: ['text', 'json', 'html', 'lcov'],
|
|
11
|
-
exclude: [
|
|
12
|
-
'node_modules/**',
|
|
13
|
-
'dist/**',
|
|
14
|
-
'**/*.config.ts',
|
|
15
|
-
'**/*.config.js',
|
|
16
|
-
'**/test/**',
|
|
17
|
-
'**/__tests__/**',
|
|
18
|
-
'prisma/**',
|
|
19
|
-
'scripts/**',
|
|
20
|
-
],
|
|
21
|
-
thresholds: {
|
|
22
|
-
lines: 80,
|
|
23
|
-
functions: 80,
|
|
24
|
-
branches: 80,
|
|
25
|
-
statements: 80,
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
setupFiles: ['./src/test/setup.ts'],
|
|
29
|
-
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
|
30
|
-
exclude: ['node_modules', 'dist'],
|
|
31
|
-
testTimeout: 10000,
|
|
32
|
-
hookTimeout: 10000,
|
|
33
|
-
},
|
|
34
|
-
resolve: {
|
|
35
|
-
alias: {
|
|
36
|
-
'@': resolve(__dirname, './src'),
|
|
37
|
-
'@modules': resolve(__dirname, './src/modules'),
|
|
38
|
-
'@libs': resolve(__dirname, './src/libs'),
|
|
39
|
-
'@config': resolve(__dirname, './src/config'),
|
|
40
|
-
'@shared': resolve(__dirname, './src/shared'),
|
|
41
|
-
},
|
|
42
|
-
},
|
|
43
|
-
});
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
globals: true,
|
|
7
|
+
environment: 'node',
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'json', 'html', 'lcov'],
|
|
11
|
+
exclude: [
|
|
12
|
+
'node_modules/**',
|
|
13
|
+
'dist/**',
|
|
14
|
+
'**/*.config.ts',
|
|
15
|
+
'**/*.config.js',
|
|
16
|
+
'**/test/**',
|
|
17
|
+
'**/__tests__/**',
|
|
18
|
+
'prisma/**',
|
|
19
|
+
'scripts/**',
|
|
20
|
+
],
|
|
21
|
+
thresholds: {
|
|
22
|
+
lines: 80,
|
|
23
|
+
functions: 80,
|
|
24
|
+
branches: 80,
|
|
25
|
+
statements: 80,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
setupFiles: ['./src/test/setup.ts'],
|
|
29
|
+
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
|
30
|
+
exclude: ['node_modules', 'dist'],
|
|
31
|
+
testTimeout: 10000,
|
|
32
|
+
hookTimeout: 10000,
|
|
33
|
+
},
|
|
34
|
+
resolve: {
|
|
35
|
+
alias: {
|
|
36
|
+
'@': resolve(__dirname, './src'),
|
|
37
|
+
'@modules': resolve(__dirname, './src/modules'),
|
|
38
|
+
'@libs': resolve(__dirname, './src/libs'),
|
|
39
|
+
'@config': resolve(__dirname, './src/config'),
|
|
40
|
+
'@shared': resolve(__dirname, './src/shared'),
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|