@dax-crafta/auth 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: '' },
@@ -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'
@@ -171,7 +210,7 @@ class AuthService {
171
210
  await user.revokeAllRefreshTokens(); // session invalidate
172
211
  user.passwordResetToken = undefined;
173
212
  user.passwordResetExpires = undefined;
174
- await this.config.auditService.logActivity({
213
+ await this.logAudit({
175
214
  userId: user._id,
176
215
  action: 'password_reset',
177
216
  status: 'success'
@@ -193,7 +232,7 @@ class AuthService {
193
232
 
194
233
  await user.setBackupCodes(codes.map(c => ({ hash: c.hash })));
195
234
 
196
- await this.config.auditService.logActivity({
235
+ await this.logAudit({
197
236
  userId,
198
237
  action: 'backup_codes_generated',
199
238
  status: 'success'
@@ -212,7 +251,7 @@ class AuthService {
212
251
  if (!user) throw new ApiError('User not found', 404);
213
252
 
214
253
  await user.safeUpdate(updates);
215
- await this.config.auditService.logActivity({
254
+ await this.logAudit({
216
255
  userId: user._id,
217
256
  action: 'profile_update',
218
257
  status: 'success'
@@ -259,6 +298,10 @@ class AuthService {
259
298
  ATOMIC LOGIN ATTEMPT INCREMENT
260
299
  --------------------------------------------- */
261
300
  async incrementLoginAttempts(user) {
301
+ if (this.config?.features?.securityAttempts === false) {
302
+ return;
303
+ }
304
+
262
305
  const MAX = this.config.maxLoginAttempts || 5;
263
306
  const now = Date.now();
264
307
  const lockUntil = now + 60 * 60 * 1000;
@@ -269,7 +312,7 @@ class AuthService {
269
312
  user.lockUntil = lockUntil;
270
313
  await user.save();
271
314
  await this.emailService.sendAccountLockEmail(user);
272
- await this.config.auditService.logActivity({
315
+ await this.logAudit({
273
316
  userId: user._id,
274
317
  action: 'account_locked',
275
318
  status: 'success'
@@ -289,7 +332,7 @@ class AuthService {
289
332
 
290
333
  await user.revokeSession(sessionId);
291
334
 
292
- await this.config.auditService.logActivity({
335
+ await this.logAudit({
293
336
  userId,
294
337
  action: 'logout',
295
338
  status: 'success'
@@ -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
 
@@ -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
+ };