@dax-crafta/auth 2.1.7 → 2.1.8

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.
@@ -59,15 +59,25 @@ const createAuthMiddleware = (config) => {
59
59
 
60
60
  // Expose function to choose limiter per-route
61
61
  const limiterFor = (routeName) => {
62
- switch (routeName) {
63
- case 'login': return loginLimiter;
64
- case '2fa': return twoFALimiter;
65
- case 'forgotPassword': return forgotPasswordLimiter;
66
- case 'refreshToken': return refreshLimiter;
67
- case 'refresh': return refreshLimiter;
68
- case 'register': return registerLimiter;
69
- default: return genericLimiter;
62
+ const routeLimits = {
63
+ login: loginLimiter,
64
+ '2fa': twoFALimiter,
65
+ forgotPassword: forgotPasswordLimiter,
66
+ refreshToken: refreshLimiter,
67
+ refresh: refreshLimiter,
68
+ register: registerLimiter
69
+ };
70
+
71
+ if (config.limits && config.limits[routeName]) {
72
+ return rateLimit({
73
+ windowMs: 15 * 60 * 1000,
74
+ max: config.limits[routeName],
75
+ standardHeaders: true,
76
+ legacyHeaders: false
77
+ });
70
78
  }
79
+
80
+ return routeLimits[routeName] || genericLimiter;
71
81
  };
72
82
 
73
83
 
@@ -93,7 +103,7 @@ const createAuthMiddleware = (config) => {
93
103
  return null;
94
104
  };
95
105
 
96
- const verifyToken = (req, res, next) => {
106
+ const verifyToken = async (req, res, next) => {
97
107
  const token = extractToken(req);
98
108
  if (!token) {
99
109
  return next(ApiError.unauthorized('No token provided'));
@@ -101,6 +111,17 @@ const createAuthMiddleware = (config) => {
101
111
 
102
112
  try {
103
113
  const decoded = jwt.verify(token, config.env.JWT_SECRET);
114
+
115
+ // Validate session version and IP binding if sessionService is available
116
+ if (req.app && req.app.sessionService) {
117
+ const User = require('../models/user');
118
+ const user = await User.findById(decoded.id);
119
+ if (!user) {
120
+ return next(ApiError.unauthorized('User not found'));
121
+ }
122
+ req.app.sessionService.validateSession(decoded, user, req.ip);
123
+ }
124
+
104
125
  // keep minimal user data on req.user
105
126
  req.user = { id: decoded.id, role: decoded.role, email: decoded.email };
106
127
  next();
@@ -48,7 +48,8 @@ const userSchema = new mongoose.Schema({
48
48
  sessionId: String,
49
49
  userAgent: String,
50
50
  ip: String,
51
- createdAt: { type: Date, default: Date.now }
51
+ createdAt: { type: Date, default: Date.now },
52
+ lastActiveAt: { type: Date, default: Date.now }
52
53
  }],
53
54
  isVerified: { type: Boolean, default: false },
54
55
  verificationToken: { type: String, select: false },
@@ -50,6 +50,72 @@ class AuditService {
50
50
  timestamp: { $gte: since }
51
51
  });
52
52
  }
53
+
54
+ /**
55
+ * Returns login attempt analytics including success rate, top failure reasons,
56
+ * and time-based patterns (hourly breakdown).
57
+ * @param {Object} filters - Optional filters like startDate, endDate, userId
58
+ */
59
+ async getLoginAttemptAnalytics(filters = {}) {
60
+ const query = { action: 'login' };
61
+ if (filters.userId) {
62
+ query.userId = filters.userId;
63
+ }
64
+ if (filters.startDate || filters.endDate) {
65
+ query.timestamp = {};
66
+ if (filters.startDate) query.timestamp.$gte = new Date(filters.startDate);
67
+ if (filters.endDate) query.timestamp.$lte = new Date(filters.endDate);
68
+ }
69
+
70
+ const totalAttempts = await AuditLog.countDocuments(query);
71
+ const successfulAttempts = await AuditLog.countDocuments({ ...query, status: 'success' });
72
+ const failedAttempts = await AuditLog.countDocuments({ ...query, status: 'failure' });
73
+
74
+ const successRate = totalAttempts > 0
75
+ ? Number(((successfulAttempts / totalAttempts) * 100).toFixed(2))
76
+ : 0;
77
+
78
+ // Top failure reasons from details.error
79
+ const topFailures = await AuditLog.aggregate([
80
+ { $match: { ...query, status: 'failure' } },
81
+ { $group: { _id: { $ifNull: ['$details.error', 'Unknown reason'] }, count: { $sum: 1 } } },
82
+ { $sort: { count: -1 } },
83
+ { $limit: 5 },
84
+ { $project: { reason: '$_id', count: 1, _id: 0 } }
85
+ ]);
86
+
87
+ // Time-based patterns: hourly distribution (0-23)
88
+ const timeBasedPatterns = await AuditLog.aggregate([
89
+ { $match: query },
90
+ {
91
+ $group: {
92
+ _id: { $hour: '$timestamp' },
93
+ total: { $sum: 1 },
94
+ success: { $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] } },
95
+ failure: { $sum: { $cond: [{ $eq: ['$status', 'failure'] }, 1, 0] } }
96
+ }
97
+ },
98
+ { $sort: { _id: 1 } },
99
+ {
100
+ $project: {
101
+ hour: '$_id',
102
+ total: 1,
103
+ success: 1,
104
+ failure: 1,
105
+ _id: 0
106
+ }
107
+ }
108
+ ]);
109
+
110
+ return {
111
+ totalAttempts,
112
+ successfulAttempts,
113
+ failedAttempts,
114
+ successRate: `${successRate}%`,
115
+ topFailures,
116
+ timeBasedPatterns
117
+ };
118
+ }
53
119
  }
54
120
 
55
121
  module.exports = AuditService;
@@ -110,6 +110,9 @@ class AuthService {
110
110
  await this.emailService.sendLoginAlert(user, deviceInfo);
111
111
  }
112
112
 
113
+ // Regenerate session ID on login if configured
114
+ await this.regenerateSessionIdOnLogin(user);
115
+
113
116
  const tokens = await this.generateTokens(user);
114
117
  return { ...tokens, user };
115
118
  }
@@ -306,31 +309,43 @@ class AuthService {
306
309
  const now = Date.now();
307
310
  const lockUntil = now + 60 * 60 * 1000;
308
311
 
309
- user.loginAttempts = (user.loginAttempts || 0) + 1;
312
+ // Perform atomic increment in DB to prevent concurrent race conditions
313
+ const updatedUser = await User.findOneAndUpdate(
314
+ { _id: user._id },
315
+ { $inc: { loginAttempts: 1 } },
316
+ { new: true }
317
+ );
310
318
 
311
- if (user.loginAttempts >= MAX) {
312
- user.lockUntil = lockUntil;
313
- await user.save();
314
- await this.emailService.sendAccountLockEmail(user);
319
+ if (updatedUser && updatedUser.loginAttempts >= MAX) {
320
+ updatedUser.lockUntil = lockUntil;
321
+ await updatedUser.save();
322
+ await this.emailService.sendAccountLockEmail(updatedUser);
315
323
  await this.logAudit({
316
- userId: user._id,
324
+ userId: updatedUser._id,
317
325
  action: 'account_locked',
318
326
  status: 'success'
319
327
  });
320
- return;
321
328
  }
322
329
 
323
- await user.save();
330
+ // Sync local user instance state
331
+ user.loginAttempts = updatedUser ? updatedUser.loginAttempts : (user.loginAttempts + 1);
332
+ if (user.loginAttempts >= MAX) {
333
+ user.lockUntil = lockUntil;
334
+ }
324
335
  }
325
336
 
326
337
  /* ---------------------------------------------
327
338
  LOGOUT
328
339
  --------------------------------------------- */
329
- async logout(userId, sessionId) {
340
+ async logout(userId, sessionId, refreshToken = null) {
330
341
  const user = await User.findById(userId);
331
342
  if (!user) throw ApiError.notFound();
332
343
 
333
344
  await user.revokeSession(sessionId);
345
+ // Also revoke the refresh token so it cannot be reused
346
+ if (refreshToken) {
347
+ await user.revokeRefreshToken(refreshToken);
348
+ }
334
349
  await user.save();
335
350
 
336
351
  await this.logAudit({
@@ -381,6 +396,18 @@ class AuthService {
381
396
  return user;
382
397
  }
383
398
 
399
+ /* ---------------------------------------------
400
+ REGENERATE SESSION ID ON LOGIN
401
+ --------------------------------------------- */
402
+ async regenerateSessionIdOnLogin(user) {
403
+ // Opt-in only — bumping sessionVersion on every login invalidates all
404
+ // existing sessions, which breaks normal multi-session usage.
405
+ if (this.config?.session?.regenerateSessionIdOnLogin === true) {
406
+ user.sessionVersion = (user.sessionVersion || 0) + 1;
407
+ await user.save();
408
+ }
409
+ }
410
+
384
411
 
385
412
 
386
413
  /* ---------------------------------------------
@@ -1,7 +1,12 @@
1
1
  const Role = require('../models/role');
2
+ const ApiError = require('../utils/api-error');
2
3
 
3
4
  class RoleService {
4
5
  async createRole(roleData) {
6
+ const existing = await Role.findOne({ name: roleData.name });
7
+ if (existing) {
8
+ throw ApiError.conflict(`Role "${roleData.name}" already exists`);
9
+ }
5
10
  const role = new Role(roleData);
6
11
  await role.save();
7
12
  return role;
@@ -14,6 +14,7 @@ class SessionService {
14
14
  this.maxSessions = (config.session && config.session.maxSessions) || 5;
15
15
  this.sessionExpiryMs =
16
16
  (config.session && config.session.sessionExpiryMs) || 30 * 24 * 60 * 60 * 1000; // 30d
17
+ this.ipBinding = (config.session && config.session.ipBinding) || false;
17
18
  }
18
19
 
19
20
  /**
@@ -46,7 +47,7 @@ class SessionService {
46
47
  * Validate that a decoded JWT's sessionVersion matches the user's current
47
48
  * version. If not, the session has been revoked globally.
48
49
  */
49
- validateSession(decoded, user) {
50
+ validateSession(decoded, user, currentIp = null) {
50
51
  const tokenVersion = decoded.sessionVersion || 0;
51
52
  const currentVersion = user.sessionVersion || 0;
52
53
  if (tokenVersion !== currentVersion) {
@@ -54,26 +55,66 @@ class SessionService {
54
55
  'Session has been invalidated. Please log in again.'
55
56
  );
56
57
  }
58
+
59
+ if (this.ipBinding && currentIp && decoded.ip && decoded.ip !== currentIp) {
60
+ throw ApiError.unauthorized(
61
+ 'Session IP address mismatch. Please log in again.'
62
+ );
63
+ }
64
+
57
65
  return true;
58
66
  }
59
67
 
60
68
  /**
61
69
  * Create a new session record for a user (used during login / social login).
70
+ *
71
+ * Tracks device info (userAgent, IP) and timestamps. If a session for the
72
+ * same device already exists, updates its activity timestamp instead of
73
+ * creating a duplicate — preventing session bloat from repeated logins.
62
74
  */
63
75
  async createSession(user, info = {}) {
64
76
  const crypto = require('crypto');
77
+ const userAgent = info.userAgent || 'unknown';
78
+ const ip = info.ip || 'unknown';
79
+
80
+ user.sessions = user.sessions || [];
81
+
82
+ // Reuse an existing session from the same device: refresh activity stamp.
83
+ const existing = user.sessions.find(
84
+ (s) => s.userAgent === userAgent && s.ip === ip
85
+ );
86
+ if (existing) {
87
+ existing.lastActiveAt = new Date();
88
+ await this.enforceSessionCap(user);
89
+ return existing;
90
+ }
91
+
65
92
  const session = {
66
93
  sessionId: crypto.randomBytes(16).toString('hex'),
67
- userAgent: info.userAgent || 'unknown',
68
- ip: info.ip || 'unknown',
69
- createdAt: new Date()
94
+ userAgent,
95
+ ip,
96
+ createdAt: new Date(),
97
+ lastActiveAt: new Date()
70
98
  };
71
- user.sessions = user.sessions || [];
72
99
  user.sessions.push(session);
73
100
  await this.enforceSessionCap(user);
74
101
  return session;
75
102
  }
76
103
 
104
+ /**
105
+ * Touch a session's activity timestamp (e.g. on token refresh).
106
+ * No-op if the session is not found.
107
+ */
108
+ async touchSession(user, sessionId) {
109
+ if (!user.sessions) return null;
110
+ const session = user.sessions.find((s) => s.sessionId === sessionId);
111
+ if (session) {
112
+ session.lastActiveAt = new Date();
113
+ await user.save();
114
+ }
115
+ return session;
116
+ }
117
+
77
118
  /**
78
119
  * Revoke a single session by sessionId.
79
120
  */
@@ -19,6 +19,13 @@ class WebhookService {
19
19
  this.events = this.enabled
20
20
  ? config.webhooks.events || ['user.registered', 'user.login', 'user.logout', 'user.password_changed']
21
21
  : [];
22
+ this.deliveryStats = {
23
+ total: 0,
24
+ success: 0,
25
+ failure: 0,
26
+ lastEvent: null,
27
+ lastStatus: null
28
+ };
22
29
  }
23
30
 
24
31
  /**
@@ -71,8 +78,18 @@ class WebhookService {
71
78
  res.setEncoding('utf8');
72
79
  res.on('data', (chunk) => (payload += chunk));
73
80
  res.on('end', () => {
81
+ const success = res.statusCode >= 200 && res.statusCode < 300;
82
+ this.deliveryStats.total++;
83
+ if (success) {
84
+ this.deliveryStats.success++;
85
+ } else {
86
+ this.deliveryStats.failure++;
87
+ }
88
+ this.deliveryStats.lastEvent = event;
89
+ this.deliveryStats.lastStatus = res.statusCode;
90
+
74
91
  resolve({
75
- delivered: res.statusCode >= 200 && res.statusCode < 300,
92
+ delivered: success,
76
93
  status: res.statusCode,
77
94
  body: payload.slice(0, 500)
78
95
  });
@@ -81,13 +98,37 @@ class WebhookService {
81
98
  );
82
99
 
83
100
  req.on('error', (err) => {
101
+ this.deliveryStats.total++;
102
+ this.deliveryStats.failure++;
103
+ this.deliveryStats.lastEvent = event;
104
+ this.deliveryStats.lastStatus = 'error';
105
+
84
106
  resolve({ delivered: false, reason: err.message });
85
107
  });
86
108
 
109
+ req.setTimeout(5000, () => {
110
+ req.destroy();
111
+ resolve({ delivered: false, reason: 'request_timeout' });
112
+ });
113
+
87
114
  req.write(body);
88
115
  req.end();
89
116
  });
90
117
  }
118
+
119
+ /**
120
+ * Returns delivery monitoring statistics.
121
+ */
122
+ getDeliveryStatus() {
123
+ const rate = this.deliveryStats.total > 0
124
+ ? (this.deliveryStats.success / this.deliveryStats.total) * 100
125
+ : 0;
126
+
127
+ return {
128
+ ...this.deliveryStats,
129
+ successRate: `${rate.toFixed(2)}%`
130
+ };
131
+ }
91
132
  }
92
133
 
93
134
  module.exports = WebhookService;
@@ -61,9 +61,10 @@ class EmailService {
61
61
 
62
62
  let content = fs.readFileSync(filePath, "utf8");
63
63
 
64
- // Replace {{variable}}
65
- for (const key of Object.keys(variables)) {
66
- content = content.replace(new RegExp(`{{${key}}}`, "g"), variables[key]);
64
+ // Replace {{variable}} using a safer approach
65
+ for (const [key, value] of Object.entries(variables)) {
66
+ const regex = new RegExp(`{{\\s*${key}\\s*}}`, "g");
67
+ content = content.replace(regex, value);
67
68
  }
68
69
 
69
70
  return content;
@@ -1,5 +1,6 @@
1
1
  // packages/auth/src/utils/password-policy.js
2
2
  const zxcvbn = require('zxcvbn');
3
+ const { getPasswordStrength } = require('./password-strength');
3
4
 
4
5
  class PasswordPolicy {
5
6
  constructor(config = {}) {
@@ -37,10 +38,14 @@ class PasswordPolicy {
37
38
  if (strength.score < this.config.minStrength)
38
39
  errors.push('Password is too weak. Please choose a stronger password');
39
40
 
41
+ // Lightweight heuristic strength score (0-4), complementing zxcvbn
42
+ const heuristicScore = getPasswordStrength(password);
43
+
40
44
  return {
41
45
  isValid: errors.length === 0,
42
46
  errors,
43
- strength: strength.score
47
+ strength: strength.score,
48
+ strengthScore: heuristicScore
44
49
  };
45
50
  }
46
51
 
@@ -0,0 +1,68 @@
1
+ // packages/auth/src/utils/password-strength.js
2
+ /**
3
+ * Password strength scoring utility.
4
+ *
5
+ * Returns a strength score from 0 (very weak) to 4 (very strong) based on
6
+ * length, character variety, and common-pattern detection.
7
+ *
8
+ * This complements the zxcvbn-based strength check inside PasswordPolicy,
9
+ * providing a lightweight, dependency-free heuristic for quick feedback.
10
+ */
11
+ const COMMON_PASSWORDS = [
12
+ 'password', '123456', '12345678', '123456789', 'qwerty', 'abc123',
13
+ '111111', '123123', 'admin', 'letmein', 'welcome', 'monkey',
14
+ 'football', 'iloveyou', 'sunshine', 'princess', 'dragon', 'baseball'
15
+ ];
16
+
17
+ const COMMON_SEQUENCES = [
18
+ 'abcdefghijklmnopqrstuvwxyz',
19
+ '0123456789',
20
+ 'qwertyuiop',
21
+ 'asdfghjkl',
22
+ 'zxcvbnm'
23
+ ];
24
+
25
+ function hasCommonSequence(password, length) {
26
+ const lower = password.toLowerCase();
27
+ for (const seq of COMMON_SEQUENCES) {
28
+ for (let i = 0; i + length <= seq.length; i++) {
29
+ if (lower.includes(seq.slice(i, i + length))) return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Calculate a strength score (0-4) for a password.
37
+ *
38
+ * @param {string} password - The password to evaluate.
39
+ * @returns {number} Score from 0 (very weak) to 4 (very strong).
40
+ */
41
+ function getPasswordStrength(password) {
42
+ if (!password || typeof password !== 'string') return 0;
43
+
44
+ const lower = password.toLowerCase();
45
+ const hasLower = /[a-z]/.test(password);
46
+ const hasUpper = /[A-Z]/.test(password);
47
+ const hasNumber = /\d/.test(password);
48
+ const hasSpecial = /[^a-zA-Z0-9]/.test(password);
49
+
50
+ let score = 0;
51
+
52
+ // Length scoring
53
+ if (password.length >= 8) score++;
54
+ if (password.length >= 12) score++;
55
+
56
+ // Complexity scoring
57
+ const varietyCount = [hasLower, hasUpper, hasNumber, hasSpecial].filter(Boolean).length;
58
+ if (varietyCount >= 3) score++;
59
+
60
+ // Penalize common patterns
61
+ if (COMMON_PASSWORDS.includes(lower)) return 0;
62
+ if (hasCommonSequence(password, 3)) score--;
63
+ if (/(.)\1{2,}/.test(password)) score--; // repeated chars like "aaa"
64
+
65
+ return Math.max(0, Math.min(4, score));
66
+ }
67
+
68
+ module.exports = { getPasswordStrength };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * 01 — Register, login, refresh, logout, profile
3
+ */
4
+ const { describe, it, before, after } = require('./tiny-test');
5
+ const assert = require('assert');
6
+ const { buildApp, startApp, stopApp, req } = require('./helpers');
7
+
8
+ let server;
9
+
10
+ describe('01-register-login', () => {
11
+ before(async () => {
12
+ const app = buildApp();
13
+ server = await startApp();
14
+ });
15
+ after(async () => { await stopApp(); });
16
+
17
+ it('register creates a user', async () => {
18
+ const res = await req('POST', '/register', {
19
+ name: 'Test User',
20
+ email: 'alice@example.com',
21
+ password: 'Strong@Pass1',
22
+ });
23
+ assert.strictEqual(res.status, 201, `expected 201, got ${res.status} ${JSON.stringify(res.body)}`);
24
+ assert.strictEqual(res.body.success, true);
25
+ assert.strictEqual(res.body.message, 'Registration successful');
26
+ });
27
+
28
+ it('register rejects duplicate email', async () => {
29
+ const res = await req('POST', '/register', {
30
+ name: 'Dup User',
31
+ email: 'alice@example.com',
32
+ password: 'Strong@Pass1',
33
+ });
34
+ assert.strictEqual(res.status, 409, `expected 409, got ${res.status}`);
35
+ assert.strictEqual(res.body.code, 'AUTH_CONFLICT');
36
+ });
37
+
38
+ it('register rejects weak password', async () => {
39
+ const res = await req('POST', '/register', {
40
+ name: 'Weak User',
41
+ email: 'weak@example.com',
42
+ password: 'abc',
43
+ });
44
+ assert.strictEqual(res.status, 400, `expected 400, got ${res.status}`);
45
+ assert.ok(res.body.error || res.body.code, 'should have error message');
46
+ });
47
+
48
+ it('login succeeds with valid creds', async () => {
49
+ const res = await req('POST', '/login', {
50
+ email: 'alice@example.com',
51
+ password: 'Strong@Pass1',
52
+ });
53
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status}`);
54
+ assert.ok(res.body.accessToken);
55
+ assert.ok(res.body.refreshToken);
56
+ });
57
+
58
+ it('login rejects wrong password', async () => {
59
+ const res = await req('POST', '/login', {
60
+ email: 'alice@example.com',
61
+ password: 'Wrong@Pass1',
62
+ });
63
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
64
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_CREDENTIALS');
65
+ });
66
+
67
+ it('login rejects unknown email', async () => {
68
+ const res = await req('POST', '/login', {
69
+ email: 'nobody@example.com',
70
+ password: 'Strong@Pass1',
71
+ });
72
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
73
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_CREDENTIALS');
74
+ });
75
+
76
+ it('refresh token rotates and old token is invalidated', async () => {
77
+ const login = await req('POST', '/login', {
78
+ email: 'alice@example.com',
79
+ password: 'Strong@Pass1',
80
+ });
81
+ const oldRefresh = login.body.refreshToken;
82
+
83
+ const refresh = await req('POST', '/refresh-token', { refreshToken: oldRefresh });
84
+ assert.strictEqual(refresh.status, 200, `expected 200, got ${refresh.status}`);
85
+ assert.ok(refresh.body.accessToken);
86
+ assert.notStrictEqual(refresh.body.refreshToken, oldRefresh, 'refresh token should rotate');
87
+
88
+ // Old token must be rejected
89
+ const reuse = await req('POST', '/refresh-token', { refreshToken: oldRefresh });
90
+ assert.strictEqual(reuse.status, 401, `old refresh token should be rejected, got ${reuse.status}`);
91
+ assert.strictEqual(reuse.body.code, 'AUTH_INVALID_TOKEN');
92
+ });
93
+
94
+ it('protected profile returns user data with valid token', async () => {
95
+ const login = await req('POST', '/login', {
96
+ email: 'alice@example.com',
97
+ password: 'Strong@Pass1',
98
+ });
99
+ const res = await req('PUT', '/profile', { name: 'Test User Updated' }, {
100
+ Authorization: `Bearer ${login.body.accessToken}`,
101
+ });
102
+ assert.strictEqual(res.status, 200, `expected 200, got ${res.status} ${JSON.stringify(res.body)}`);
103
+ assert.strictEqual(res.body.user.email, 'alice@example.com');
104
+ });
105
+
106
+ it('protected route rejects missing token', async () => {
107
+ const res = await req('PUT', '/profile', { name: 'X' });
108
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
109
+ assert.strictEqual(res.body.code, 'AUTH_UNAUTHORIZED');
110
+ });
111
+
112
+ it('protected route rejects invalid token', async () => {
113
+ const res = await req('PUT', '/profile', { name: 'X' }, {
114
+ Authorization: 'Bearer not-a-real-token',
115
+ });
116
+ assert.strictEqual(res.status, 401, `expected 401, got ${res.status}`);
117
+ assert.strictEqual(res.body.code, 'AUTH_INVALID_TOKEN');
118
+ });
119
+
120
+ it('logout revokes refresh token', async () => {
121
+ const login = await req('POST', '/login', {
122
+ email: 'alice@example.com',
123
+ password: 'Strong@Pass1',
124
+ });
125
+ const logout = await req('POST', '/logout', { refreshToken: login.body.refreshToken }, {
126
+ Authorization: `Bearer ${login.body.accessToken}`,
127
+ });
128
+ assert.strictEqual(logout.status, 200, `expected 200, got ${logout.status} ${JSON.stringify(logout.body)}`);
129
+
130
+ // Refresh token should now be dead
131
+ const after = await req('POST', '/refresh-token', { refreshToken: login.body.refreshToken });
132
+ assert.strictEqual(after.status, 401, `expected 401 after logout, got ${after.status}`);
133
+ });
134
+ });