@dax-crafta/auth 2.1.5 → 2.1.7

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,6 +48,15 @@ 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
62
  switch (routeName) {
@@ -54,19 +65,29 @@ const createAuthMiddleware = (config) => {
54
65
  case 'forgotPassword': return forgotPasswordLimiter;
55
66
  case 'refreshToken': return refreshLimiter;
56
67
  case 'refresh': return refreshLimiter;
68
+ case 'register': return registerLimiter;
57
69
  default: return genericLimiter;
58
70
  }
59
71
  };
60
72
 
61
73
 
62
74
  const extractToken = (req) => {
63
- const authHeader = req.headers.authorization;
64
- if (authHeader && authHeader.startsWith('Bearer ')) {
75
+ const headers = req.headers || {};
76
+ let authHeader = headers.authorization;
77
+ // Guard: duplicate headers arrive as arrays — take first string element
78
+ if (Array.isArray(authHeader)) {
79
+ authHeader = authHeader.find(h => typeof h === 'string') || null;
80
+ }
81
+ if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
65
82
  return authHeader.split(' ')[1];
66
83
  }
67
84
 
68
- if (req.headers['x-access-token']) {
69
- return req.headers['x-access-token'];
85
+ let xAccess = headers['x-access-token'];
86
+ if (Array.isArray(xAccess)) {
87
+ xAccess = xAccess.find(h => typeof h === 'string') || null;
88
+ }
89
+ if (typeof xAccess === 'string') {
90
+ return xAccess;
70
91
  }
71
92
 
72
93
  return null;
@@ -75,7 +96,7 @@ const createAuthMiddleware = (config) => {
75
96
  const verifyToken = (req, res, next) => {
76
97
  const token = extractToken(req);
77
98
  if (!token) {
78
- return res.status(401).json({ success: false, error: 'No token provided' });
99
+ return next(ApiError.unauthorized('No token provided'));
79
100
  }
80
101
 
81
102
  try {
@@ -84,17 +105,20 @@ const createAuthMiddleware = (config) => {
84
105
  req.user = { id: decoded.id, role: decoded.role, email: decoded.email };
85
106
  next();
86
107
  } catch (err) {
87
- return res.status(401).json({ success: false, error: 'Invalid or expired token' });
108
+ if (err.name === 'TokenExpiredError') {
109
+ return next(ApiError.expiredToken());
110
+ }
111
+ return next(ApiError.invalidToken());
88
112
  }
89
113
  };
90
114
 
91
115
  const checkRole = (roles = []) => {
92
116
  return (req, res, next) => {
93
117
  if (!req.user) {
94
- return res.status(401).json({ success: false, error: 'Unauthorized' });
118
+ return next(ApiError.unauthorized());
95
119
  }
96
120
  if (!roles.includes(req.user.role)) {
97
- return res.status(403).json({ success: false, error: 'Insufficient permissions' });
121
+ return next(ApiError.forbidden());
98
122
  }
99
123
  next();
100
124
  };
@@ -105,13 +129,13 @@ const createAuthMiddleware = (config) => {
105
129
  return async (req, res, next) => {
106
130
  try {
107
131
  const targetId = typeof getTargetUserId === 'function' ? getTargetUserId(req) : req.params.id;
108
- if (!req.user) return res.status(401).json({ success: false, error: 'Unauthorized' });
132
+ if (!req.user) return next(ApiError.unauthorized());
109
133
  if (req.user.role === 'admin' || String(req.user.id) === String(targetId)) {
110
134
  return next();
111
135
  }
112
- return res.status(403).json({ success: false, error: 'Insufficient permissions to modify this resource' });
136
+ return next(ApiError.forbidden('Insufficient permissions to modify this resource'));
113
137
  } catch (err) {
114
- return res.status(500).json({ success: false, error: 'Server error' });
138
+ return next(ApiError.internal('Server error'));
115
139
  }
116
140
  };
117
141
  };
@@ -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;
@@ -51,6 +52,8 @@ const userSchema = new mongoose.Schema({
51
52
  }],
52
53
  isVerified: { type: Boolean, default: false },
53
54
  verificationToken: { type: String, select: false },
55
+ magicLinkToken: { type: String, select: false },
56
+ magicLinkExpires: { type: Date, select: false },
54
57
  passwordResetToken: { type: String, select: false },
55
58
  passwordResetExpires: { type: Date, select: false },
56
59
  loginAttempts: { type: Number, default: 0 },
@@ -58,11 +61,13 @@ const userSchema = new mongoose.Schema({
58
61
  twoFactorSecret: { type: String, select: false },
59
62
  twoFactorEnabled: { type: Boolean, default: false },
60
63
  refreshTokens: { type: [refreshTokenSchema], default: [] },
64
+ sessionVersion: { type: Number, default: 0 },
61
65
  customFields: { type: mongoose.Schema.Types.Mixed }
62
66
  }, { timestamps: true });
63
67
 
64
68
  // Indexes
65
69
  userSchema.index({ email: 1 }, { unique: true });
70
+ userSchema.index({ magicLinkToken: 1 });
66
71
  userSchema.index({ 'refreshTokens.expires': 1 }); // helps queries that prune expired refresh tokens
67
72
  userSchema.index({ lockUntil: 1 });
68
73
 
@@ -90,9 +95,13 @@ userSchema.methods.comparePassword = async function (candidate) {
90
95
  };
91
96
 
92
97
  userSchema.methods.recordPasswordHistory = async function (previousPasswordHash) {
98
+ if (typeof previousPasswordHash !== 'string' || previousPasswordHash.length === 0) {
99
+ throw ApiError.internal('recordPasswordHistory requires previousPasswordHash');
100
+ }
101
+
93
102
  this.passwordHistory = this.passwordHistory || [];
94
103
  this.passwordHistory.push({
95
- hash: previousPasswordHash || this.password,
104
+ hash: previousPasswordHash,
96
105
  changedAt: new Date()
97
106
  });
98
107
 
@@ -100,8 +109,6 @@ userSchema.methods.recordPasswordHistory = async function (previousPasswordHash)
100
109
  if (this.passwordHistory.length > 5) {
101
110
  this.passwordHistory.shift();
102
111
  }
103
-
104
- await this.save();
105
112
  };
106
113
 
107
114
  userSchema.methods.setBackupCodes = async function (codes) {
@@ -109,7 +116,6 @@ userSchema.methods.setBackupCodes = async function (codes) {
109
116
  hash: c.hash,
110
117
  used: false
111
118
  }));
112
- await this.save();
113
119
  };
114
120
 
115
121
 
@@ -124,20 +130,17 @@ userSchema.methods.addRefreshToken = async function (tokenString, expiresAt) {
124
130
  token: tokenString,
125
131
  expires: expiresAt
126
132
  });
127
- await this.save();
128
133
  return tokenString;
129
134
  };
130
135
 
131
136
  // Revoke a specific refresh token (by token string)
132
137
  userSchema.methods.revokeRefreshToken = async function (tokenString) {
133
138
  this.refreshTokens = (this.refreshTokens || []).filter(t => t.token !== tokenString);
134
- await this.save();
135
139
  };
136
140
 
137
141
  // Revoke all refresh tokens (e.g., on password reset)
138
142
  userSchema.methods.revokeAllRefreshTokens = async function () {
139
143
  this.refreshTokens = [];
140
- await this.save();
141
144
  };
142
145
 
143
146
  // Remove expired refresh tokens (useful to call periodically or on sensitive flows)
@@ -166,17 +169,14 @@ userSchema.methods.addSession = async function (info) {
166
169
  userAgent: info.userAgent,
167
170
  ip: info.ip
168
171
  });
169
- await this.save();
170
172
  };
171
173
 
172
174
  userSchema.methods.revokeSession = async function (sessionId) {
173
175
  this.sessions = this.sessions.filter(s => s.sessionId !== sessionId);
174
- await this.save();
175
176
  };
176
177
 
177
178
  userSchema.methods.revokeAllSessions = async function () {
178
179
  this.sessions = [];
179
- await this.save();
180
180
  };
181
181
 
182
182
 
@@ -195,7 +195,6 @@ userSchema.methods.verifyBackupCode = async function (rawCode, mfaService) {
195
195
  this.backupCodes = this.backupCodes.map((c) =>
196
196
  c.hash === match.hash ? { ...c.toObject?.() ?? c, used: true } : c
197
197
  );
198
- await this.save();
199
198
  return true;
200
199
  };
201
200
 
@@ -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
@@ -120,9 +120,9 @@ class AuthService {
120
120
  async verify2FA(userId, code) {
121
121
  const user = await User.findById(userId).select('+twoFactorSecret');
122
122
 
123
- if (!user) throw new ApiError('User not found', 404);
123
+ if (!user) throw ApiError.notFound('User not found');
124
124
  if (!user.twoFactorEnabled)
125
- throw new ApiError('2FA is not enabled for this user', 400);
125
+ throw ApiError.badRequest('2FA is not enabled for this user');
126
126
 
127
127
  const ok = authenticator.verify({
128
128
  token: code,
@@ -132,7 +132,7 @@ class AuthService {
132
132
  // fallback: backup codes
133
133
  if (!ok) {
134
134
  const backupOK = await user.verifyBackupCode(code, this.config.mfaService);
135
- if (!backupOK) throw new ApiError('Invalid 2FA code', 401);
135
+ if (!backupOK) throw ApiError.twoFactorFailed();
136
136
  }
137
137
 
138
138
 
@@ -147,10 +147,11 @@ class AuthService {
147
147
  // find only valid (non-expired) tokens
148
148
  const user = await User.findByRefreshToken(refreshToken);
149
149
 
150
- if (!user) throw new ApiError('Invalid or expired refresh token', 401);
150
+ if (!user) throw ApiError.invalidToken();
151
151
 
152
152
  // Refresh token rotation (best practice)
153
- await user.revokeRefreshToken(refreshToken);
153
+ user.revokeRefreshToken(refreshToken);
154
+ await user.save();
154
155
 
155
156
  const tokens = await this.generateTokens(user);
156
157
  await this.logAudit({
@@ -191,24 +192,20 @@ class AuthService {
191
192
  passwordResetExpires: { $gt: Date.now() }
192
193
  });
193
194
 
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
- );
195
+ if (!user) throw ApiError.badRequest('Invalid or expired reset token');
202
196
 
203
- if (reused.includes(true)) {
204
- throw new ApiError('Cannot reuse previous passwords', 400);
197
+ // check password history (sequential, short-circuits on first match)
198
+ for (const old of (user.passwordHistory || [])) {
199
+ if (await bcrypt.compare(newPassword, old.hash)) {
200
+ throw ApiError.passwordReuse('Cannot reuse previous passwords');
201
+ }
205
202
  }
206
203
 
207
204
  const previousPasswordHash = user.password;
208
205
  user.password = newPassword;
209
206
 
210
207
  await user.recordPasswordHistory(previousPasswordHash);
211
- await user.revokeAllRefreshTokens(); // session invalidate
208
+ user.revokeAllRefreshTokens(); // session invalidate
212
209
  user.passwordResetToken = undefined;
213
210
  user.passwordResetExpires = undefined;
214
211
  await this.logAudit({
@@ -217,7 +214,6 @@ class AuthService {
217
214
  status: 'success'
218
215
  });
219
216
 
220
-
221
217
  await user.save();
222
218
  return user;
223
219
  }
@@ -227,11 +223,12 @@ class AuthService {
227
223
  --------------------------------------------- */
228
224
  async generateBackupCodes(userId, mfaService) {
229
225
  const user = await User.findById(userId);
230
- if (!user) throw new ApiError("User not found", 404);
226
+ if (!user) throw ApiError.notFound();
231
227
 
232
228
  const codes = mfaService.generateBackupCodes();
233
229
 
234
230
  await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
231
+ await user.save();
235
232
 
236
233
  await this.logAudit({
237
234
  userId,
@@ -249,7 +246,7 @@ class AuthService {
249
246
  --------------------------------------------- */
250
247
  async updateProfile(userId, updates) {
251
248
  const user = await User.findById(userId);
252
- if (!user) throw new ApiError('User not found', 404);
249
+ if (!user) throw ApiError.notFound();
253
250
 
254
251
  await user.safeUpdate(updates);
255
252
  await this.logAudit({
@@ -269,7 +266,8 @@ class AuthService {
269
266
  {
270
267
  id: user._id,
271
268
  email: user.email,
272
- role: user.role
269
+ role: user.role,
270
+ sessionVersion: user.sessionVersion || 0
273
271
  },
274
272
  this.config.env.JWT_SECRET,
275
273
  { expiresIn: this.config.accessTokenExpiry || '1h' }
@@ -280,7 +278,8 @@ class AuthService {
280
278
  Date.now() +
281
279
  (this.config.refreshTokenDays || 7) * 24 * 60 * 60 * 1000;
282
280
 
283
- await user.addRefreshToken(refreshToken, expiresAt);
281
+ user.addRefreshToken(refreshToken, expiresAt);
282
+ await user.save();
284
283
 
285
284
  // prune expired refresh tokens in background
286
285
  await user.pruneExpiredRefreshTokens();
@@ -329,9 +328,10 @@ class AuthService {
329
328
  --------------------------------------------- */
330
329
  async logout(userId, sessionId) {
331
330
  const user = await User.findById(userId);
332
- if (!user) throw new ApiError("User not found", 404);
331
+ if (!user) throw ApiError.notFound();
333
332
 
334
333
  await user.revokeSession(sessionId);
334
+ await user.save();
335
335
 
336
336
  await this.logAudit({
337
337
  userId,
@@ -344,12 +344,12 @@ class AuthService {
344
344
 
345
345
  async handleSocialLogin(provider, profile) {
346
346
  if (!profile) {
347
- throw new ApiError('Social profile is required', 400);
347
+ throw ApiError.badRequest('Social profile is required');
348
348
  }
349
349
 
350
350
  const primaryEmail = profile.emails?.[0]?.value;
351
351
  if (!primaryEmail) {
352
- throw new ApiError('Social provider did not return an email', 400);
352
+ throw ApiError.badRequest('Social provider did not return an email');
353
353
  }
354
354
 
355
355
  let user = await User.findOne({ email: primaryEmail });
@@ -366,13 +366,15 @@ class AuthService {
366
366
  }
367
367
  });
368
368
  await user.save();
369
- } else if (profile.displayName) {
369
+ } else {
370
370
  user.customFields = {
371
371
  ...(user.customFields || {}),
372
372
  socialProvider: provider,
373
- socialId: profile.id,
374
- name: profile.displayName
373
+ socialId: profile.id
375
374
  };
375
+ if (profile.displayName) {
376
+ user.customFields.name = profile.displayName;
377
+ }
376
378
  await user.save();
377
379
  }
378
380
 
@@ -0,0 +1,66 @@
1
+ // packages/auth/src/services/passwordless.service.js
2
+ /**
3
+ * PasswordlessService — magic-link login.
4
+ *
5
+ * Flow:
6
+ * 1. POST /auth/magic-link { email } → creates a single-use token, emails it
7
+ * 2. GET /auth/magic-link/verify?token=... → validates, logs user in
8
+ *
9
+ * Tokens are short-lived (default 15 min) and single-use.
10
+ */
11
+ const crypto = require('crypto');
12
+ const ApiError = require('../utils/api-error');
13
+
14
+ class PasswordlessService {
15
+ constructor(config = {}) {
16
+ this.config = config;
17
+ this.emailService = config.emailService;
18
+ this.tokenTtlMs = (config.passwordless && config.passwordless.tokenTtlMs) || 15 * 60 * 1000;
19
+ }
20
+
21
+ generateToken() {
22
+ return crypto.randomBytes(32).toString('hex');
23
+ }
24
+
25
+ async requestMagicLink(email, baseUrl) {
26
+ const User = require('../models/user');
27
+ const user = await User.findOne({ email });
28
+ // Don't reveal whether the email exists — just return success either way.
29
+ if (!user) return { sent: false, reason: 'user_not_found' };
30
+
31
+ const token = this.generateToken();
32
+ user.magicLinkToken = token;
33
+ user.magicLinkExpires = Date.now() + this.tokenTtlMs;
34
+ await user.save();
35
+
36
+ const linkPath = this.config.routes?.magicLink || '/auth/magic-link/verify';
37
+ const url = `${baseUrl || ''}${linkPath}?token=${token}`;
38
+
39
+ if (this.emailService) {
40
+ await this.emailService.sendMagicLink(user, url);
41
+ }
42
+
43
+ return { sent: true };
44
+ }
45
+
46
+ async verifyMagicLink(token) {
47
+ const User = require('../models/user');
48
+ const user = await User.findOne({
49
+ magicLinkToken: token,
50
+ magicLinkExpires: { $gt: Date.now() }
51
+ }).select('+magicLinkToken +magicLinkExpires');
52
+
53
+ if (!user) {
54
+ throw ApiError.invalidToken('Invalid or expired magic link');
55
+ }
56
+
57
+ // Single-use: consume the token.
58
+ user.magicLinkToken = undefined;
59
+ user.magicLinkExpires = undefined;
60
+ await user.save();
61
+
62
+ return user;
63
+ }
64
+ }
65
+
66
+ module.exports = PasswordlessService;
@@ -0,0 +1,100 @@
1
+ // packages/auth/src/services/session.service.js
2
+ /**
3
+ * SessionService — manages user sessions with a global "session version"
4
+ * stamp so that a password change, admin action, or logout-all can invalidate
5
+ * every existing JWT issued before the event.
6
+ *
7
+ * This is the backbone of true "log out everywhere" behaviour.
8
+ */
9
+ const ApiError = require('../utils/api-error');
10
+
11
+ class SessionService {
12
+ constructor(config = {}) {
13
+ this.config = config;
14
+ this.maxSessions = (config.session && config.session.maxSessions) || 5;
15
+ this.sessionExpiryMs =
16
+ (config.session && config.session.sessionExpiryMs) || 30 * 24 * 60 * 60 * 1000; // 30d
17
+ }
18
+
19
+ /**
20
+ * Bump the user's session version. Every existing JWT issued before this
21
+ * moment becomes invalid.
22
+ */
23
+ async bumpSessionVersion(user) {
24
+ user.sessionVersion = (user.sessionVersion || 0) + 1;
25
+ await user.save();
26
+ return user.sessionVersion;
27
+ }
28
+
29
+ /**
30
+ * Enforce per-user session cap. If the user has more active sessions than
31
+ * `maxSessions`, drop the oldest ones.
32
+ */
33
+ async enforceSessionCap(user) {
34
+ const sessions = user.sessions || [];
35
+ if (sessions.length <= this.maxSessions) return;
36
+
37
+ // sort oldest-first by createdAt
38
+ sessions.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
39
+ const toRemove = sessions.length - this.maxSessions;
40
+ sessions.splice(0, toRemove);
41
+ user.sessions = sessions;
42
+ await user.save();
43
+ }
44
+
45
+ /**
46
+ * Validate that a decoded JWT's sessionVersion matches the user's current
47
+ * version. If not, the session has been revoked globally.
48
+ */
49
+ validateSession(decoded, user) {
50
+ const tokenVersion = decoded.sessionVersion || 0;
51
+ const currentVersion = user.sessionVersion || 0;
52
+ if (tokenVersion !== currentVersion) {
53
+ throw ApiError.unauthorized(
54
+ 'Session has been invalidated. Please log in again.'
55
+ );
56
+ }
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * Create a new session record for a user (used during login / social login).
62
+ */
63
+ async createSession(user, info = {}) {
64
+ const crypto = require('crypto');
65
+ const session = {
66
+ sessionId: crypto.randomBytes(16).toString('hex'),
67
+ userAgent: info.userAgent || 'unknown',
68
+ ip: info.ip || 'unknown',
69
+ createdAt: new Date()
70
+ };
71
+ user.sessions = user.sessions || [];
72
+ user.sessions.push(session);
73
+ await this.enforceSessionCap(user);
74
+ return session;
75
+ }
76
+
77
+ /**
78
+ * Revoke a single session by sessionId.
79
+ */
80
+ async revokeSession(user, sessionId) {
81
+ if (!user.sessions) return;
82
+ user.sessions = user.sessions.filter((s) => s.sessionId !== sessionId);
83
+ await user.save();
84
+ }
85
+
86
+ /**
87
+ * Revoke ALL sessions for a user (logout everywhere).
88
+ */
89
+ async revokeAllSessions(user) {
90
+ user.sessions = [];
91
+ await this.save(user);
92
+ return this.bumpSessionVersion(user);
93
+ }
94
+
95
+ async save(user) {
96
+ await user.save();
97
+ }
98
+ }
99
+
100
+ module.exports = SessionService;