@dax-crafta/auth 2.1.6 → 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.
@@ -2,6 +2,8 @@
2
2
  const rateLimit = require('express-rate-limit');
3
3
  const jwt = require('jsonwebtoken');
4
4
 
5
+ const ApiError = require('../utils/api-error');
6
+
5
7
  const createAuthMiddleware = (config) => {
6
8
  const noOpLimiter = (_req, _res, next) => next();
7
9
  const rateLimitEnabled = config?.features?.rateLimit !== false;
@@ -46,55 +48,98 @@ const createAuthMiddleware = (config) => {
46
48
  message: { success: false, error: 'Too many refresh attempts' }
47
49
  }) : noOpLimiter;
48
50
 
51
+ // Register limiter
52
+ const registerLimiter = rateLimitEnabled ? rateLimit({
53
+ windowMs: 15 * 60 * 1000,
54
+ max: config?.limits?.registerMax || 20,
55
+ message: { success: false, error: 'Too many registration attempts, please try later' },
56
+ standardHeaders: true,
57
+ legacyHeaders: false
58
+ }) : noOpLimiter;
59
+
49
60
  // Expose function to choose limiter per-route
50
61
  const limiterFor = (routeName) => {
51
- switch (routeName) {
52
- case 'login': return loginLimiter;
53
- case '2fa': return twoFALimiter;
54
- case 'forgotPassword': return forgotPasswordLimiter;
55
- case 'refreshToken': return refreshLimiter;
56
- case 'refresh': return refreshLimiter;
57
- 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
+ });
58
78
  }
79
+
80
+ return routeLimits[routeName] || genericLimiter;
59
81
  };
60
82
 
61
83
 
62
84
  const extractToken = (req) => {
63
- const authHeader = req.headers.authorization;
64
- if (authHeader && authHeader.startsWith('Bearer ')) {
85
+ const headers = req.headers || {};
86
+ let authHeader = headers.authorization;
87
+ // Guard: duplicate headers arrive as arrays — take first string element
88
+ if (Array.isArray(authHeader)) {
89
+ authHeader = authHeader.find(h => typeof h === 'string') || null;
90
+ }
91
+ if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
65
92
  return authHeader.split(' ')[1];
66
93
  }
67
94
 
68
- if (req.headers['x-access-token']) {
69
- return req.headers['x-access-token'];
95
+ let xAccess = headers['x-access-token'];
96
+ if (Array.isArray(xAccess)) {
97
+ xAccess = xAccess.find(h => typeof h === 'string') || null;
98
+ }
99
+ if (typeof xAccess === 'string') {
100
+ return xAccess;
70
101
  }
71
102
 
72
103
  return null;
73
104
  };
74
105
 
75
- const verifyToken = (req, res, next) => {
106
+ const verifyToken = async (req, res, next) => {
76
107
  const token = extractToken(req);
77
108
  if (!token) {
78
- return res.status(401).json({ success: false, error: 'No token provided' });
109
+ return next(ApiError.unauthorized('No token provided'));
79
110
  }
80
111
 
81
112
  try {
82
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
+
83
125
  // keep minimal user data on req.user
84
126
  req.user = { id: decoded.id, role: decoded.role, email: decoded.email };
85
127
  next();
86
128
  } catch (err) {
87
- return res.status(401).json({ success: false, error: 'Invalid or expired token' });
129
+ if (err.name === 'TokenExpiredError') {
130
+ return next(ApiError.expiredToken());
131
+ }
132
+ return next(ApiError.invalidToken());
88
133
  }
89
134
  };
90
135
 
91
136
  const checkRole = (roles = []) => {
92
137
  return (req, res, next) => {
93
138
  if (!req.user) {
94
- return res.status(401).json({ success: false, error: 'Unauthorized' });
139
+ return next(ApiError.unauthorized());
95
140
  }
96
141
  if (!roles.includes(req.user.role)) {
97
- return res.status(403).json({ success: false, error: 'Insufficient permissions' });
142
+ return next(ApiError.forbidden());
98
143
  }
99
144
  next();
100
145
  };
@@ -105,13 +150,13 @@ const createAuthMiddleware = (config) => {
105
150
  return async (req, res, next) => {
106
151
  try {
107
152
  const targetId = typeof getTargetUserId === 'function' ? getTargetUserId(req) : req.params.id;
108
- if (!req.user) return res.status(401).json({ success: false, error: 'Unauthorized' });
153
+ if (!req.user) return next(ApiError.unauthorized());
109
154
  if (req.user.role === 'admin' || String(req.user.id) === String(targetId)) {
110
155
  return next();
111
156
  }
112
- return res.status(403).json({ success: false, error: 'Insufficient permissions to modify this resource' });
157
+ return next(ApiError.forbidden('Insufficient permissions to modify this resource'));
113
158
  } catch (err) {
114
- return res.status(500).json({ success: false, error: 'Server error' });
159
+ return next(ApiError.internal('Server error'));
115
160
  }
116
161
  };
117
162
  };
@@ -0,0 +1,73 @@
1
+ // packages/auth/src/middlewares/cors.middleware.js
2
+ /**
3
+ * CORS middleware — explicit origin whitelist instead of the default
4
+ * "reflect any origin" behaviour, which is a security risk for auth APIs.
5
+ *
6
+ * If `config.cors` is omitted, we default to a safe deny-everything policy
7
+ * for auth routes (they should be consumed by a trusted frontend).
8
+ */
9
+ const ApiError = require('../utils/api-error');
10
+
11
+ const DEFAULT_CORS = {
12
+ origin: '*',
13
+ credentials: true,
14
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
15
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
16
+ maxAge: 86400
17
+ };
18
+
19
+ function normalizeOrigin(origin) {
20
+ if (!origin) return null;
21
+ try {
22
+ const u = new URL(origin);
23
+ return `${u.protocol}//${u.host}`;
24
+ } catch {
25
+ return origin;
26
+ }
27
+ }
28
+
29
+ function corsMiddleware(config = {}) {
30
+ const cors = { ...DEFAULT_CORS, ...(config.cors || {}) };
31
+
32
+ // Build the set of allowed origins once.
33
+ const allowed = Array.isArray(cors.origin)
34
+ ? cors.origin.map(normalizeOrigin)
35
+ : cors.origin === '*'
36
+ ? null // null means "reflect any" (explicit opt-in)
37
+ : [normalizeOrigin(cors.origin)];
38
+
39
+ return (req, res, next) => {
40
+ const requestOrigin = req.headers.origin;
41
+
42
+ // Always set safe headers for preflight.
43
+ if (req.method === 'OPTIONS') {
44
+ res.setHeader('Access-Control-Allow-Methods', cors.methods.join(','));
45
+ res.setHeader('Access-Control-Allow-Headers', cors.allowedHeaders.join(','));
46
+ res.setHeader('Access-Control-Max-Age', String(cors.maxAge));
47
+ if (cors.credentials) res.setHeader('Access-Control-Allow-Credentials', 'true');
48
+ if (allowed === null) {
49
+ if (requestOrigin) res.setHeader('Access-Control-Allow-Origin', requestOrigin);
50
+ } else if (allowed.includes(normalizeOrigin(requestOrigin))) {
51
+ res.setHeader('Access-Control-Allow-Origin', requestOrigin);
52
+ }
53
+ return res.status(204).end();
54
+ }
55
+
56
+ // Actual request.
57
+ if (allowed === null) {
58
+ // Reflect any — only safe when the app explicitly opts in.
59
+ if (requestOrigin) res.setHeader('Access-Control-Allow-Origin', requestOrigin);
60
+ } else if (requestOrigin && allowed.includes(normalizeOrigin(requestOrigin))) {
61
+ res.setHeader('Access-Control-Allow-Origin', requestOrigin);
62
+ res.setHeader('Access-Control-Allow-Credentials', String(cors.credentials));
63
+ } else if (requestOrigin) {
64
+ // Origin not in whitelist — block it.
65
+ return next(ApiError.forbidden('Origin not allowed by CORS policy'));
66
+ }
67
+
68
+ if (cors.credentials) res.setHeader('Access-Control-Allow-Credentials', 'true');
69
+ next();
70
+ };
71
+ }
72
+
73
+ module.exports = corsMiddleware;
@@ -43,8 +43,9 @@ const profileUpdateValidator = [
43
43
  body().custom((value, { req }) => {
44
44
  // prevent sensitive fields in payload
45
45
  const forbidden = ['password', 'role', 'refreshTokens', 'isVerified', 'verificationToken', 'twoFactorSecret', '_id'];
46
+ const body = req.body || {};
46
47
  for (const f of forbidden) {
47
- if (req.body.hasOwnProperty(f)) {
48
+ if (Object.prototype.hasOwnProperty.call(body, f)) {
48
49
  throw new Error(`Cannot update field: ${f}`);
49
50
  }
50
51
  }
@@ -3,6 +3,7 @@ const mongoose = require('mongoose');
3
3
  const bcrypt = require('bcryptjs');
4
4
  const validator = require('validator');
5
5
  const crypto = require('crypto');
6
+ const ApiError = require('../utils/api-error');
6
7
 
7
8
 
8
9
  const SALT_ROUNDS = Number(process.env.SALT_ROUNDS) || 12;
@@ -47,10 +48,13 @@ const userSchema = new mongoose.Schema({
47
48
  sessionId: String,
48
49
  userAgent: String,
49
50
  ip: String,
50
- createdAt: { type: Date, default: Date.now }
51
+ createdAt: { type: Date, default: Date.now },
52
+ lastActiveAt: { type: Date, default: Date.now }
51
53
  }],
52
54
  isVerified: { type: Boolean, default: false },
53
55
  verificationToken: { type: String, select: false },
56
+ magicLinkToken: { type: String, select: false },
57
+ magicLinkExpires: { type: Date, select: false },
54
58
  passwordResetToken: { type: String, select: false },
55
59
  passwordResetExpires: { type: Date, select: false },
56
60
  loginAttempts: { type: Number, default: 0 },
@@ -58,11 +62,13 @@ const userSchema = new mongoose.Schema({
58
62
  twoFactorSecret: { type: String, select: false },
59
63
  twoFactorEnabled: { type: Boolean, default: false },
60
64
  refreshTokens: { type: [refreshTokenSchema], default: [] },
65
+ sessionVersion: { type: Number, default: 0 },
61
66
  customFields: { type: mongoose.Schema.Types.Mixed }
62
67
  }, { timestamps: true });
63
68
 
64
69
  // Indexes
65
70
  userSchema.index({ email: 1 }, { unique: true });
71
+ userSchema.index({ magicLinkToken: 1 });
66
72
  userSchema.index({ 'refreshTokens.expires': 1 }); // helps queries that prune expired refresh tokens
67
73
  userSchema.index({ lockUntil: 1 });
68
74
 
@@ -90,9 +96,13 @@ userSchema.methods.comparePassword = async function (candidate) {
90
96
  };
91
97
 
92
98
  userSchema.methods.recordPasswordHistory = async function (previousPasswordHash) {
99
+ if (typeof previousPasswordHash !== 'string' || previousPasswordHash.length === 0) {
100
+ throw ApiError.internal('recordPasswordHistory requires previousPasswordHash');
101
+ }
102
+
93
103
  this.passwordHistory = this.passwordHistory || [];
94
104
  this.passwordHistory.push({
95
- hash: previousPasswordHash || this.password,
105
+ hash: previousPasswordHash,
96
106
  changedAt: new Date()
97
107
  });
98
108
 
@@ -100,8 +110,6 @@ userSchema.methods.recordPasswordHistory = async function (previousPasswordHash)
100
110
  if (this.passwordHistory.length > 5) {
101
111
  this.passwordHistory.shift();
102
112
  }
103
-
104
- await this.save();
105
113
  };
106
114
 
107
115
  userSchema.methods.setBackupCodes = async function (codes) {
@@ -109,7 +117,6 @@ userSchema.methods.setBackupCodes = async function (codes) {
109
117
  hash: c.hash,
110
118
  used: false
111
119
  }));
112
- await this.save();
113
120
  };
114
121
 
115
122
 
@@ -124,20 +131,17 @@ userSchema.methods.addRefreshToken = async function (tokenString, expiresAt) {
124
131
  token: tokenString,
125
132
  expires: expiresAt
126
133
  });
127
- await this.save();
128
134
  return tokenString;
129
135
  };
130
136
 
131
137
  // Revoke a specific refresh token (by token string)
132
138
  userSchema.methods.revokeRefreshToken = async function (tokenString) {
133
139
  this.refreshTokens = (this.refreshTokens || []).filter(t => t.token !== tokenString);
134
- await this.save();
135
140
  };
136
141
 
137
142
  // Revoke all refresh tokens (e.g., on password reset)
138
143
  userSchema.methods.revokeAllRefreshTokens = async function () {
139
144
  this.refreshTokens = [];
140
- await this.save();
141
145
  };
142
146
 
143
147
  // Remove expired refresh tokens (useful to call periodically or on sensitive flows)
@@ -166,17 +170,14 @@ userSchema.methods.addSession = async function (info) {
166
170
  userAgent: info.userAgent,
167
171
  ip: info.ip
168
172
  });
169
- await this.save();
170
173
  };
171
174
 
172
175
  userSchema.methods.revokeSession = async function (sessionId) {
173
176
  this.sessions = this.sessions.filter(s => s.sessionId !== sessionId);
174
- await this.save();
175
177
  };
176
178
 
177
179
  userSchema.methods.revokeAllSessions = async function () {
178
180
  this.sessions = [];
179
- await this.save();
180
181
  };
181
182
 
182
183
 
@@ -195,7 +196,6 @@ userSchema.methods.verifyBackupCode = async function (rawCode, mfaService) {
195
196
  this.backupCodes = this.backupCodes.map((c) =>
196
197
  c.hash === match.hash ? { ...c.toObject?.() ?? c, used: true } : c
197
198
  );
198
- await this.save();
199
199
  return true;
200
200
  };
201
201
 
@@ -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;
@@ -47,12 +47,12 @@ class AuthService {
47
47
  --------------------------------------------- */
48
48
  async verifyEmail(token) {
49
49
  if (!token) {
50
- throw new ApiError('Verification token is required', 400);
50
+ throw ApiError.badRequest('Verification token is required');
51
51
  }
52
52
 
53
53
  const user = await User.findOne({ verificationToken: token }).select('+verificationToken');
54
54
  if (!user) {
55
- throw new ApiError('Invalid or expired verification token', 400);
55
+ throw ApiError.badRequest('Invalid or expired verification token');
56
56
  }
57
57
 
58
58
  user.isVerified = true;
@@ -71,17 +71,17 @@ class AuthService {
71
71
  /* ---------------------------------------------
72
72
  LOGIN USER (WITH ATOMIC LOCKOUT)
73
73
  --------------------------------------------- */
74
- async login(email, password, deviceInfo) {
74
+ async login(email, password, deviceInfo = {}) {
75
75
  const user = await User.findOne({ email });
76
76
 
77
77
  if (!user) {
78
- throw new ApiError('Invalid credentials', 401);
78
+ throw ApiError.invalidCredentials();
79
79
  }
80
80
 
81
81
  // If locked, do NOT allow login
82
82
  const securityAttemptsEnabled = this.config?.features?.securityAttempts !== false;
83
83
  if (securityAttemptsEnabled && user.isLocked()) {
84
- throw new ApiError('Account locked. Try again later.', 403);
84
+ throw ApiError.accountLocked();
85
85
  }
86
86
 
87
87
  const isValid = await user.comparePassword(password);
@@ -89,7 +89,7 @@ class AuthService {
89
89
  if (securityAttemptsEnabled) {
90
90
  await this.incrementLoginAttempts(user);
91
91
  }
92
- throw new ApiError('Invalid credentials', 401);
92
+ throw ApiError.invalidCredentials();
93
93
  }
94
94
 
95
95
  // RESET LOCKOUT automatically on correct login
@@ -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
  }
@@ -120,9 +123,9 @@ class AuthService {
120
123
  async verify2FA(userId, code) {
121
124
  const user = await User.findById(userId).select('+twoFactorSecret');
122
125
 
123
- if (!user) throw new ApiError('User not found', 404);
126
+ if (!user) throw ApiError.notFound('User not found');
124
127
  if (!user.twoFactorEnabled)
125
- throw new ApiError('2FA is not enabled for this user', 400);
128
+ throw ApiError.badRequest('2FA is not enabled for this user');
126
129
 
127
130
  const ok = authenticator.verify({
128
131
  token: code,
@@ -132,7 +135,7 @@ class AuthService {
132
135
  // fallback: backup codes
133
136
  if (!ok) {
134
137
  const backupOK = await user.verifyBackupCode(code, this.config.mfaService);
135
- if (!backupOK) throw new ApiError('Invalid 2FA code', 401);
138
+ if (!backupOK) throw ApiError.twoFactorFailed();
136
139
  }
137
140
 
138
141
 
@@ -147,10 +150,11 @@ class AuthService {
147
150
  // find only valid (non-expired) tokens
148
151
  const user = await User.findByRefreshToken(refreshToken);
149
152
 
150
- if (!user) throw new ApiError('Invalid or expired refresh token', 401);
153
+ if (!user) throw ApiError.invalidToken();
151
154
 
152
155
  // Refresh token rotation (best practice)
153
- await user.revokeRefreshToken(refreshToken);
156
+ user.revokeRefreshToken(refreshToken);
157
+ await user.save();
154
158
 
155
159
  const tokens = await this.generateTokens(user);
156
160
  await this.logAudit({
@@ -191,24 +195,20 @@ class AuthService {
191
195
  passwordResetExpires: { $gt: Date.now() }
192
196
  });
193
197
 
194
- if (!user) throw new ApiError('Invalid or expired reset token', 400);
195
-
196
- // check password history
197
- const reused = await Promise.all(
198
- (user.passwordHistory || []).map(async (old) =>
199
- bcrypt.compare(newPassword, old.hash)
200
- )
201
- );
198
+ if (!user) throw ApiError.badRequest('Invalid or expired reset token');
202
199
 
203
- if (reused.includes(true)) {
204
- throw new ApiError('Cannot reuse previous passwords', 400);
200
+ // check password history (sequential, short-circuits on first match)
201
+ for (const old of (user.passwordHistory || [])) {
202
+ if (await bcrypt.compare(newPassword, old.hash)) {
203
+ throw ApiError.passwordReuse('Cannot reuse previous passwords');
204
+ }
205
205
  }
206
206
 
207
207
  const previousPasswordHash = user.password;
208
208
  user.password = newPassword;
209
209
 
210
210
  await user.recordPasswordHistory(previousPasswordHash);
211
- await user.revokeAllRefreshTokens(); // session invalidate
211
+ user.revokeAllRefreshTokens(); // session invalidate
212
212
  user.passwordResetToken = undefined;
213
213
  user.passwordResetExpires = undefined;
214
214
  await this.logAudit({
@@ -217,7 +217,6 @@ class AuthService {
217
217
  status: 'success'
218
218
  });
219
219
 
220
-
221
220
  await user.save();
222
221
  return user;
223
222
  }
@@ -227,11 +226,12 @@ class AuthService {
227
226
  --------------------------------------------- */
228
227
  async generateBackupCodes(userId, mfaService) {
229
228
  const user = await User.findById(userId);
230
- if (!user) throw new ApiError("User not found", 404);
229
+ if (!user) throw ApiError.notFound();
231
230
 
232
231
  const codes = mfaService.generateBackupCodes();
233
232
 
234
233
  await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
234
+ await user.save();
235
235
 
236
236
  await this.logAudit({
237
237
  userId,
@@ -249,7 +249,7 @@ class AuthService {
249
249
  --------------------------------------------- */
250
250
  async updateProfile(userId, updates) {
251
251
  const user = await User.findById(userId);
252
- if (!user) throw new ApiError('User not found', 404);
252
+ if (!user) throw ApiError.notFound();
253
253
 
254
254
  await user.safeUpdate(updates);
255
255
  await this.logAudit({
@@ -269,7 +269,8 @@ class AuthService {
269
269
  {
270
270
  id: user._id,
271
271
  email: user.email,
272
- role: user.role
272
+ role: user.role,
273
+ sessionVersion: user.sessionVersion || 0
273
274
  },
274
275
  this.config.env.JWT_SECRET,
275
276
  { expiresIn: this.config.accessTokenExpiry || '1h' }
@@ -280,7 +281,8 @@ class AuthService {
280
281
  Date.now() +
281
282
  (this.config.refreshTokenDays || 7) * 24 * 60 * 60 * 1000;
282
283
 
283
- await user.addRefreshToken(refreshToken, expiresAt);
284
+ user.addRefreshToken(refreshToken, expiresAt);
285
+ await user.save();
284
286
 
285
287
  // prune expired refresh tokens in background
286
288
  await user.pruneExpiredRefreshTokens();
@@ -307,31 +309,44 @@ class AuthService {
307
309
  const now = Date.now();
308
310
  const lockUntil = now + 60 * 60 * 1000;
309
311
 
310
- 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
+ );
311
318
 
312
- if (user.loginAttempts >= MAX) {
313
- user.lockUntil = lockUntil;
314
- await user.save();
315
- 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);
316
323
  await this.logAudit({
317
- userId: user._id,
324
+ userId: updatedUser._id,
318
325
  action: 'account_locked',
319
326
  status: 'success'
320
327
  });
321
- return;
322
328
  }
323
329
 
324
- 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
+ }
325
335
  }
326
336
 
327
337
  /* ---------------------------------------------
328
338
  LOGOUT
329
339
  --------------------------------------------- */
330
- async logout(userId, sessionId) {
340
+ async logout(userId, sessionId, refreshToken = null) {
331
341
  const user = await User.findById(userId);
332
- if (!user) throw new ApiError("User not found", 404);
342
+ if (!user) throw ApiError.notFound();
333
343
 
334
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
+ }
349
+ await user.save();
335
350
 
336
351
  await this.logAudit({
337
352
  userId,
@@ -344,12 +359,12 @@ class AuthService {
344
359
 
345
360
  async handleSocialLogin(provider, profile) {
346
361
  if (!profile) {
347
- throw new ApiError('Social profile is required', 400);
362
+ throw ApiError.badRequest('Social profile is required');
348
363
  }
349
364
 
350
365
  const primaryEmail = profile.emails?.[0]?.value;
351
366
  if (!primaryEmail) {
352
- throw new ApiError('Social provider did not return an email', 400);
367
+ throw ApiError.badRequest('Social provider did not return an email');
353
368
  }
354
369
 
355
370
  let user = await User.findOne({ email: primaryEmail });
@@ -366,19 +381,33 @@ class AuthService {
366
381
  }
367
382
  });
368
383
  await user.save();
369
- } else if (profile.displayName) {
384
+ } else {
370
385
  user.customFields = {
371
386
  ...(user.customFields || {}),
372
387
  socialProvider: provider,
373
- socialId: profile.id,
374
- name: profile.displayName
388
+ socialId: profile.id
375
389
  };
390
+ if (profile.displayName) {
391
+ user.customFields.name = profile.displayName;
392
+ }
376
393
  await user.save();
377
394
  }
378
395
 
379
396
  return user;
380
397
  }
381
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
+
382
411
 
383
412
 
384
413
  /* ---------------------------------------------