@dax-crafta/auth 2.1.1 → 2.1.3

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.
@@ -3,38 +3,48 @@ const rateLimit = require('express-rate-limit');
3
3
  const jwt = require('jsonwebtoken');
4
4
 
5
5
  const createAuthMiddleware = (config) => {
6
+ const noOpLimiter = (_req, _res, next) => next();
7
+ const rateLimitEnabled = config?.features?.rateLimit !== false;
8
+
6
9
  // Default generic limiter (used for non-sensitive endpoints)
7
- const genericLimiter = rateLimit({
10
+ const genericLimiter = rateLimitEnabled ? rateLimit({
8
11
  windowMs: 15 * 60 * 1000, // 15 minutes
9
12
  max: 100,
10
13
  standardHeaders: true,
11
14
  legacyHeaders: false
12
- });
15
+ }) : noOpLimiter;
13
16
 
14
17
  // Sensitive endpoint limiters - tuned for production
15
- const loginLimiter = rateLimit({
18
+ const loginLimiter = rateLimitEnabled ? rateLimit({
16
19
  windowMs: 15 * 60 * 1000,
17
20
  max: config?.limits?.loginMax || 10, // default 10 attempts per 15m
18
21
  message: { success: false, error: 'Too many login attempts, please try later' },
19
22
  standardHeaders: true,
20
23
  legacyHeaders: false
21
- });
24
+ }) : noOpLimiter;
22
25
 
23
- const twoFALimiter = rateLimit({
26
+ const twoFALimiter = rateLimitEnabled ? rateLimit({
24
27
  windowMs: 15 * 60 * 1000,
25
28
  max: config?.limits?.twoFAMax || 20, // 20 per 15m
26
29
  message: { success: false, error: 'Too many 2FA attempts, please try later' },
27
30
  standardHeaders: true,
28
31
  legacyHeaders: false
29
- });
32
+ }) : noOpLimiter;
30
33
 
31
- const forgotPasswordLimiter = rateLimit({
34
+ const forgotPasswordLimiter = rateLimitEnabled ? rateLimit({
32
35
  windowMs: 60 * 60 * 1000,
33
36
  max: config?.limits?.forgotPasswordMax || 5, // 5 per hour
34
37
  message: { success: false, error: 'Too many password reset requests, please try later' },
35
38
  standardHeaders: true,
36
39
  legacyHeaders: false
37
- });
40
+ }) : noOpLimiter;
41
+
42
+ // Refresh token limiter
43
+ const refreshLimiter = rateLimitEnabled ? rateLimit({
44
+ windowMs: 15 * 60 * 1000,
45
+ max: config?.limits?.refreshMax || 30,
46
+ message: { success: false, error: 'Too many refresh attempts' }
47
+ }) : noOpLimiter;
38
48
 
39
49
  // Expose function to choose limiter per-route
40
50
  const limiterFor = (routeName) => {
@@ -42,18 +52,12 @@ const createAuthMiddleware = (config) => {
42
52
  case 'login': return loginLimiter;
43
53
  case '2fa': return twoFALimiter;
44
54
  case 'forgotPassword': return forgotPasswordLimiter;
55
+ case 'refreshToken': return refreshLimiter;
45
56
  case 'refresh': return refreshLimiter;
46
57
  default: return genericLimiter;
47
58
  }
48
59
  };
49
60
 
50
- // Refresh token limiter
51
- const refreshLimiter = rateLimit({
52
- windowMs: 15 * 60 * 1000,
53
- max: config?.limits?.refreshMax || 30,
54
- message: { success: false, error: 'Too many refresh attempts' }
55
- });
56
-
57
61
 
58
62
  const verifyToken = (req, res, next) => {
59
63
  const header = req.headers.authorization;
@@ -10,7 +10,24 @@ const auditLogSchema = new mongoose.Schema({
10
10
  action: {
11
11
  type: String,
12
12
  required: true,
13
- enum: ['login', 'logout', 'password_change', 'profile_update', 'role_change', '2fa_enabled', '2fa_disabled', 'register', 'password_reset_request', 'password_reset']
13
+ enum: [
14
+ 'login',
15
+ 'logout',
16
+ 'password_change',
17
+ 'profile_update',
18
+ 'role_change',
19
+ '2fa_enabled',
20
+ '2fa_disabled',
21
+ '2fa_verify',
22
+ 'register',
23
+ 'password_reset_request',
24
+ 'password_reset',
25
+ 'email_verify',
26
+ 'account_locked',
27
+ 'refresh_token',
28
+ 'backup_codes_generated',
29
+ 'social_login'
30
+ ]
14
31
  },
15
32
  ipAddress: { type: String, default: '' },
16
33
  userAgent: { type: String, default: '' },
@@ -89,10 +89,10 @@ userSchema.methods.comparePassword = async function (candidate) {
89
89
  return bcrypt.compare(candidate, this.password);
90
90
  };
91
91
 
92
- userSchema.methods.recordPasswordHistory = async function () {
92
+ userSchema.methods.recordPasswordHistory = async function (previousPasswordHash) {
93
93
  this.passwordHistory = this.passwordHistory || [];
94
94
  this.passwordHistory.push({
95
- hash: this.password,
95
+ hash: previousPasswordHash || this.password,
96
96
  changedAt: new Date()
97
97
  });
98
98
 
@@ -180,6 +180,24 @@ userSchema.methods.revokeAllSessions = async function () {
180
180
  };
181
181
 
182
182
 
183
+ // Verify a backup code using MFA service and mark it as used
184
+ userSchema.methods.verifyBackupCode = async function (rawCode, mfaService) {
185
+ if (!mfaService || !this.backupCodes || this.backupCodes.length === 0) {
186
+ return false;
187
+ }
188
+
189
+ const match = mfaService.verifyBackupCode(rawCode, this.backupCodes);
190
+ if (!match) {
191
+ return false;
192
+ }
193
+
194
+ // Mark the matched code as used and persist
195
+ this.backupCodes = this.backupCodes.map((c) =>
196
+ c.hash === match.hash ? { ...c.toObject?.() ?? c, used: true } : c
197
+ );
198
+ await this.save();
199
+ return true;
200
+ };
183
201
 
184
202
  // Static helper: find user by refresh token and prune expired ones while returning valid user
185
203
  userSchema.statics.findByRefreshToken = async function (tokenString) {
@@ -4,7 +4,7 @@ const { authenticator } = require('otplib');
4
4
  const crypto = require('crypto');
5
5
  const User = require('../models/user');
6
6
  const EmailService = require('../utils/email');
7
- const { ApiError } = require('..'); // from index.js
7
+ const ApiError = require('../utils/api-error');
8
8
 
9
9
  class AuthService {
10
10
  constructor(config) {
@@ -12,6 +12,15 @@ class AuthService {
12
12
  this.emailService = new EmailService(config);
13
13
  }
14
14
 
15
+ async logAudit(entry) {
16
+ if (!this.config?.auditService) return;
17
+ try {
18
+ await this.config.auditService.logActivity(entry);
19
+ } catch (_err) {
20
+ // Audit failures should not break auth flows.
21
+ }
22
+ }
23
+
15
24
  /* ---------------------------------------------
16
25
  REGISTER USER
17
26
  --------------------------------------------- */
@@ -33,6 +42,32 @@ class AuthService {
33
42
  return user;
34
43
  }
35
44
 
45
+ /* ---------------------------------------------
46
+ VERIFY EMAIL
47
+ --------------------------------------------- */
48
+ async verifyEmail(token) {
49
+ if (!token) {
50
+ throw new ApiError('Verification token is required', 400);
51
+ }
52
+
53
+ const user = await User.findOne({ verificationToken: token }).select('+verificationToken');
54
+ if (!user) {
55
+ throw new ApiError('Invalid or expired verification token', 400);
56
+ }
57
+
58
+ user.isVerified = true;
59
+ user.verificationToken = undefined;
60
+ await user.save();
61
+
62
+ await this.logAudit({
63
+ userId: user._id,
64
+ action: 'email_verify',
65
+ status: 'success'
66
+ });
67
+
68
+ return user;
69
+ }
70
+
36
71
  /* ---------------------------------------------
37
72
  LOGIN USER (WITH ATOMIC LOCKOUT)
38
73
  --------------------------------------------- */
@@ -44,13 +79,16 @@ class AuthService {
44
79
  }
45
80
 
46
81
  // If locked, do NOT allow login
47
- if (user.isLocked()) {
82
+ const securityAttemptsEnabled = this.config?.features?.securityAttempts !== false;
83
+ if (securityAttemptsEnabled && user.isLocked()) {
48
84
  throw new ApiError('Account locked. Try again later.', 403);
49
85
  }
50
86
 
51
87
  const isValid = await user.comparePassword(password);
52
88
  if (!isValid) {
53
- await this.incrementLoginAttempts(user);
89
+ if (securityAttemptsEnabled) {
90
+ await this.incrementLoginAttempts(user);
91
+ }
54
92
  throw new ApiError('Invalid credentials', 401);
55
93
  }
56
94
 
@@ -60,7 +98,8 @@ class AuthService {
60
98
  await user.save();
61
99
 
62
100
  // If 2FA is enabled
63
- if (user.twoFactorEnabled) {
101
+ const twoFactorEnabled = this.config?.features?.twoFactor !== false;
102
+ if (twoFactorEnabled && user.twoFactorEnabled) {
64
103
  const code = authenticator.generate(user.twoFactorSecret);
65
104
  await this.emailService.send2FACode(user, code);
66
105
  return { requires2FA: true, userId: user._id };
@@ -114,7 +153,7 @@ class AuthService {
114
153
  await user.revokeRefreshToken(refreshToken);
115
154
 
116
155
  const tokens = await this.generateTokens(user);
117
- await this.config.auditService.logActivity({
156
+ await this.logAudit({
118
157
  userId: user._id,
119
158
  action: 'refresh_token',
120
159
  status: 'success'
@@ -165,13 +204,14 @@ class AuthService {
165
204
  throw new ApiError('Cannot reuse previous passwords', 400);
166
205
  }
167
206
 
207
+ const previousPasswordHash = user.password;
168
208
  user.password = newPassword;
169
209
 
170
- await user.recordPasswordHistory(); // 🔥 add
210
+ await user.recordPasswordHistory(previousPasswordHash);
171
211
  await user.revokeAllRefreshTokens(); // session invalidate
172
212
  user.passwordResetToken = undefined;
173
213
  user.passwordResetExpires = undefined;
174
- await this.config.auditService.logActivity({
214
+ await this.logAudit({
175
215
  userId: user._id,
176
216
  action: 'password_reset',
177
217
  status: 'success'
@@ -193,7 +233,7 @@ class AuthService {
193
233
 
194
234
  await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
195
235
 
196
- await this.config.auditService.logActivity({
236
+ await this.logAudit({
197
237
  userId,
198
238
  action: 'backup_codes_generated',
199
239
  status: 'success'
@@ -212,7 +252,7 @@ class AuthService {
212
252
  if (!user) throw new ApiError('User not found', 404);
213
253
 
214
254
  await user.safeUpdate(updates);
215
- await this.config.auditService.logActivity({
255
+ await this.logAudit({
216
256
  userId: user._id,
217
257
  action: 'profile_update',
218
258
  status: 'success'
@@ -259,6 +299,10 @@ class AuthService {
259
299
  ATOMIC LOGIN ATTEMPT INCREMENT
260
300
  --------------------------------------------- */
261
301
  async incrementLoginAttempts(user) {
302
+ if (this.config?.features?.securityAttempts === false) {
303
+ return;
304
+ }
305
+
262
306
  const MAX = this.config.maxLoginAttempts || 5;
263
307
  const now = Date.now();
264
308
  const lockUntil = now + 60 * 60 * 1000;
@@ -269,7 +313,7 @@ class AuthService {
269
313
  user.lockUntil = lockUntil;
270
314
  await user.save();
271
315
  await this.emailService.sendAccountLockEmail(user);
272
- await this.config.auditService.logActivity({
316
+ await this.logAudit({
273
317
  userId: user._id,
274
318
  action: 'account_locked',
275
319
  status: 'success'
@@ -289,7 +333,7 @@ class AuthService {
289
333
 
290
334
  await user.revokeSession(sessionId);
291
335
 
292
- await this.config.auditService.logActivity({
336
+ await this.logAudit({
293
337
  userId,
294
338
  action: 'logout',
295
339
  status: 'success'
@@ -298,6 +342,43 @@ class AuthService {
298
342
  return true;
299
343
  }
300
344
 
345
+ async handleSocialLogin(provider, profile) {
346
+ if (!profile) {
347
+ throw new ApiError('Social profile is required', 400);
348
+ }
349
+
350
+ const primaryEmail = profile.emails?.[0]?.value;
351
+ if (!primaryEmail) {
352
+ throw new ApiError('Social provider did not return an email', 400);
353
+ }
354
+
355
+ let user = await User.findOne({ email: primaryEmail });
356
+
357
+ if (!user) {
358
+ user = new User({
359
+ email: primaryEmail,
360
+ password: this.generateSecureToken(),
361
+ isVerified: true,
362
+ customFields: {
363
+ socialProvider: provider,
364
+ socialId: profile.id,
365
+ name: profile.displayName
366
+ }
367
+ });
368
+ await user.save();
369
+ } else if (profile.displayName) {
370
+ user.customFields = {
371
+ ...(user.customFields || {}),
372
+ socialProvider: provider,
373
+ socialId: profile.id,
374
+ name: profile.displayName
375
+ };
376
+ await user.save();
377
+ }
378
+
379
+ return user;
380
+ }
381
+
301
382
 
302
383
 
303
384
  /* ---------------------------------------------
@@ -0,0 +1,74 @@
1
+ const { createLogger } = require('../utils/logger');
2
+
3
+ // Feature-aware test catalog (mocked; replace run methods with real tests)
4
+ const testCatalog = [
5
+ {
6
+ name: 'JWT signing',
7
+ feature: 'jwt',
8
+ run: async (cfg, log) => {
9
+ if (!cfg?.env?.JWT_SECRET) throw new Error('JWT_SECRET missing');
10
+ log.logInfo('JWT secret present; token signing would proceed');
11
+ }
12
+ },
13
+ {
14
+ name: 'Email verification/OTP delivery',
15
+ feature: 'email',
16
+ run: async (cfg, log) => {
17
+ if (!cfg?.smtp) throw new Error('SMTP config missing');
18
+ log.logInfo(`SMTP host: ${cfg.smtp.host}`);
19
+ }
20
+ },
21
+ {
22
+ name: 'OAuth (Google)',
23
+ feature: 'oauth',
24
+ run: async (cfg, log) => {
25
+ if (!cfg?.social?.google) throw new Error('Google config missing');
26
+ log.logInfo('Google OAuth config present; would start redirect flow');
27
+ }
28
+ },
29
+ {
30
+ name: '2FA (TOTP)',
31
+ feature: 'twofa',
32
+ run: async (_cfg, log) => {
33
+ log.logInfo('2FA code verification simulated');
34
+ }
35
+ }
36
+ ];
37
+
38
+ function inferFeatureFlags(authConfig = {}) {
39
+ return {
40
+ jwt: true, // core always on
41
+ email: !!(authConfig.emailVerification && authConfig.smtp),
42
+ oauth: !!(authConfig.social && authConfig.social.google),
43
+ twofa: true // library supports 2FA routes by default
44
+ };
45
+ }
46
+
47
+ async function runAdaptiveTests(authConfig = {}, featureFlags, opts = {}) {
48
+ const flags = featureFlags || inferFeatureFlags(authConfig);
49
+ const log = createLogger(opts.logging !== false);
50
+
51
+ log.logInfo('Adaptive test runner starting…');
52
+
53
+ for (const test of testCatalog) {
54
+ if (!flags[test.feature]) {
55
+ log.logSkip(`${test.name} - feature disabled`);
56
+ continue;
57
+ }
58
+ try {
59
+ await test.run(authConfig, log);
60
+ log.logSuccess(test.name);
61
+ } catch (err) {
62
+ log.logError(`${test.name} failed: ${err.message}`);
63
+ }
64
+ }
65
+
66
+ log.logSuccess('Adaptive tests complete.');
67
+ return true;
68
+ }
69
+
70
+ module.exports = {
71
+ runAdaptiveTests,
72
+ inferFeatureFlags,
73
+ testCatalog
74
+ };
@@ -0,0 +1,8 @@
1
+ class ApiError extends Error {
2
+ constructor(message, status = 400) {
3
+ super(message);
4
+ this.status = status;
5
+ }
6
+ }
7
+
8
+ module.exports = ApiError;
@@ -6,13 +6,26 @@ const path = require('path');
6
6
  class EmailService {
7
7
  constructor(config) {
8
8
  this.config = config;
9
- this.transporter = nodemailer.createTransport(config.smtp);
10
- this.from = config.smtp.from;
9
+ this.smtpEnabled = !!(
10
+ config.smtp &&
11
+ config.smtp.host &&
12
+ config.smtp.port &&
13
+ config.smtp.auth &&
14
+ config.smtp.auth.user &&
15
+ config.smtp.auth.pass &&
16
+ config.smtp.from
17
+ );
18
+ this.transporter = this.smtpEnabled ? nodemailer.createTransport(config.smtp) : null;
19
+ this.from = this.smtpEnabled ? config.smtp.from : 'noreply@localhost';
11
20
  this.templateDir = config.emailTemplateDir || path.join(process.cwd(), "templates/email");
12
21
  }
13
22
 
14
23
  // Reusable sender
15
24
  async send({ to, subject, html, text }) {
25
+ if (!this.smtpEnabled) {
26
+ return { sent: false, reason: 'smtp_disabled' };
27
+ }
28
+
16
29
  try {
17
30
  await this.transporter.sendMail({
18
31
  from: this.from,
@@ -21,8 +34,10 @@ class EmailService {
21
34
  html,
22
35
  text: text || html.replace(/<[^>]+>/g, '')
23
36
  });
37
+ return { sent: true };
24
38
  } catch (err) {
25
39
  console.error("Email send error:", err);
40
+ return { sent: false, reason: err.message };
26
41
  }
27
42
  }
28
43
 
@@ -42,7 +57,8 @@ class EmailService {
42
57
  }
43
58
 
44
59
  async sendVerificationEmail(user, token) {
45
- const url = `${this.config.baseUrl}/verify-email?token=${token}`;
60
+ const verifyPath = this.config.routes?.verify || '/verify';
61
+ const url = `${this.config.baseUrl}${verifyPath}?token=${token}`;
46
62
 
47
63
  const html = this.loadTemplate("email-verification", {
48
64
  name: user.name || user.email,
@@ -58,7 +74,8 @@ class EmailService {
58
74
  }
59
75
 
60
76
  async sendPasswordResetEmail(user, token) {
61
- const url = `${this.config.baseUrl}/reset-password?token=${token}`;
77
+ const resetPath = this.config.routes?.resetPassword || '/reset-password';
78
+ const url = `${this.config.baseUrl}${resetPath}?token=${token}`;
62
79
 
63
80
  const html = this.loadTemplate("password-reset", {
64
81
  name: user.name || user.email,
@@ -0,0 +1,38 @@
1
+ // Lightweight terminal logger with colorized prefixes.
2
+ const COLORS = {
3
+ reset: '\x1b[0m',
4
+ green: '\x1b[32m',
5
+ yellow: '\x1b[33m',
6
+ red: '\x1b[31m',
7
+ cyan: '\x1b[36m',
8
+ gray: '\x1b[90m'
9
+ };
10
+
11
+ const prefix = {
12
+ success: `${COLORS.green}[SUCCESS]${COLORS.reset}`,
13
+ info: `${COLORS.cyan}[INFO]${COLORS.reset}`,
14
+ warn: `${COLORS.yellow}[WARN]${COLORS.reset}`,
15
+ error: `${COLORS.red}[FAIL]${COLORS.reset}`,
16
+ skip: `${COLORS.gray}[SKIP]${COLORS.reset}`
17
+ };
18
+
19
+ function maybeLog(enabled, fn) {
20
+ return (msg) => {
21
+ if (enabled === false) return;
22
+ fn(msg);
23
+ };
24
+ }
25
+
26
+ function createLogger(enabled = true) {
27
+ return {
28
+ logSuccess: maybeLog(enabled, (msg) => console.log(`${prefix.success} ${msg}`)),
29
+ logInfo: maybeLog(enabled, (msg) => console.log(`${prefix.info} ${msg}`)),
30
+ logWarn: maybeLog(enabled, (msg) => console.warn(`${prefix.warn} ${msg}`)),
31
+ logError: maybeLog(enabled, (msg) => console.error(`${prefix.error} ${msg}`)),
32
+ logSkip: maybeLog(enabled, (msg) => console.log(`${prefix.skip} ${msg}`))
33
+ };
34
+ }
35
+
36
+ module.exports = {
37
+ createLogger
38
+ };