@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.
- package/.mocharc.cjs +6 -0
- package/README.md +167 -274
- package/image.png +0 -0
- package/package.json +6 -2
- package/src/index.d.ts +213 -107
- package/src/index.js +669 -442
- package/src/middlewares/auth.middleware.js +64 -19
- package/src/middlewares/cors.middleware.js +73 -0
- package/src/middlewares/validation.middleware.js +2 -1
- package/src/models/user.js +12 -12
- package/src/services/audit.service.js +66 -0
- package/src/services/auth.service.js +71 -42
- package/src/services/passwordless.service.js +66 -0
- package/src/services/role.service.js +5 -0
- package/src/services/session.service.js +141 -0
- package/src/services/webhook.service.js +134 -0
- package/src/utils/api-error.js +165 -8
- package/src/utils/email.js +57 -11
- package/src/utils/mfa.js +27 -18
- package/src/utils/password-policy.js +6 -1
- package/src/utils/password-strength.js +68 -0
- package/test/01-register-login.test.js +134 -0
- package/test/02-password-reset.test.js +50 -0
- package/test/03-2fa.test.js +54 -0
- package/test/04-rbac.test.js +51 -0
- package/test/05-audit-webhook.test.js +78 -0
- package/test/06-sessions.test.js +36 -0
- package/test/07-error-normalization.test.js +64 -0
- package/test/08-cors-health.test.js +45 -0
- package/test/09-passwordless.test.js +40 -0
- package/test/10-webhook-signing.test.js +37 -0
- package/test/helpers.js +130 -0
- package/test/run-tests.js +94 -0
- package/test/tiny-test.js +68 -0
package/src/index.js
CHANGED
|
@@ -1,442 +1,669 @@
|
|
|
1
|
-
const passport = require('passport');
|
|
2
|
-
const cookieParser = require('cookie-parser');
|
|
3
|
-
const csrf = require('csurf');
|
|
4
|
-
const helmet = require("helmet");
|
|
5
|
-
const mongoose = require("mongoose");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
logger.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
} catch (err) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
1
|
+
const passport = require('passport');
|
|
2
|
+
const cookieParser = require('cookie-parser');
|
|
3
|
+
const csrf = require('csurf');
|
|
4
|
+
const helmet = require("helmet");
|
|
5
|
+
const mongoose = require("mongoose");
|
|
6
|
+
const jwt = require('jsonwebtoken');
|
|
7
|
+
|
|
8
|
+
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
|
9
|
+
const AuthService = require('./services/auth.service');
|
|
10
|
+
const RoleService = require('./services/role.service');
|
|
11
|
+
const AuditService = require('./services/audit.service');
|
|
12
|
+
const SessionService = require('./services/session.service');
|
|
13
|
+
const WebhookService = require('./services/webhook.service');
|
|
14
|
+
const PasswordlessService = require('./services/passwordless.service');
|
|
15
|
+
const MFAService = require('./utils/mfa');
|
|
16
|
+
const PasswordPolicy = require('./utils/password-policy');
|
|
17
|
+
const createAuthMiddleware = require('./middlewares/auth.middleware');
|
|
18
|
+
const corsMiddleware = require('./middlewares/cors.middleware');
|
|
19
|
+
const validators = require('./middlewares/validation.middleware');
|
|
20
|
+
const { createLogger } = require('./utils/logger');
|
|
21
|
+
const ApiError = require('./utils/api-error');
|
|
22
|
+
const { getPasswordStrength } = require('./utils/password-strength');
|
|
23
|
+
const { runAdaptiveTests, inferFeatureFlags, testCatalog } = require('./testing/adaptive-runner');
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
const defaultConfig = {
|
|
27
|
+
strategy: 'jwt',
|
|
28
|
+
fields: ['email', 'password'],
|
|
29
|
+
routes: {
|
|
30
|
+
register: '/register',
|
|
31
|
+
login: '/login',
|
|
32
|
+
verify: '/verify',
|
|
33
|
+
forgotPassword: '/forgot-password',
|
|
34
|
+
resetPassword: '/reset-password',
|
|
35
|
+
refreshToken: '/refresh-token',
|
|
36
|
+
profile: '/profile',
|
|
37
|
+
twoFactor: '/2fa',
|
|
38
|
+
roles: '/roles',
|
|
39
|
+
permissions: '/permissions',
|
|
40
|
+
magicLink: '/auth/magic-link',
|
|
41
|
+
magicLinkVerify: '/auth/magic-link/verify'
|
|
42
|
+
},
|
|
43
|
+
mongoUrl: process.env.MONGODB_URI || 'mongodb://localhost:27017/forge-auth',
|
|
44
|
+
maxLoginAttempts: 5,
|
|
45
|
+
emailVerification: true,
|
|
46
|
+
loginAlerts: true,
|
|
47
|
+
passwordPolicy: {
|
|
48
|
+
minLength: 8,
|
|
49
|
+
requireUppercase: true,
|
|
50
|
+
requireNumbers: true,
|
|
51
|
+
requireSpecialChars: true,
|
|
52
|
+
expiryDays: 90
|
|
53
|
+
},
|
|
54
|
+
smtp: null,
|
|
55
|
+
social: {
|
|
56
|
+
google: null,
|
|
57
|
+
facebook: null,
|
|
58
|
+
github: null
|
|
59
|
+
},
|
|
60
|
+
features: {
|
|
61
|
+
emailVerification: true,
|
|
62
|
+
loginAlerts: true,
|
|
63
|
+
securityAttempts: true,
|
|
64
|
+
rateLimit: true,
|
|
65
|
+
auditLogs: true,
|
|
66
|
+
twoFactor: true,
|
|
67
|
+
csrf: false,
|
|
68
|
+
passwordless: false,
|
|
69
|
+
webhooks: false
|
|
70
|
+
},
|
|
71
|
+
env: {
|
|
72
|
+
JWT_SECRET: process.env.JWT_SECRET || 'crafta-auth-dev-secret'
|
|
73
|
+
},
|
|
74
|
+
accessTokenExpiry: '1h',
|
|
75
|
+
refreshTokenDays: 7,
|
|
76
|
+
cors: {
|
|
77
|
+
origin: '*',
|
|
78
|
+
credentials: true
|
|
79
|
+
},
|
|
80
|
+
webhooks: null,
|
|
81
|
+
session: {
|
|
82
|
+
maxSessions: 5,
|
|
83
|
+
sessionExpiryMs: 30 * 24 * 60 * 60 * 1000,
|
|
84
|
+
warningThresholdMs: 5 * 60 * 1000
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function auth(config = {}) {
|
|
89
|
+
const finalConfig = {
|
|
90
|
+
...defaultConfig,
|
|
91
|
+
...config,
|
|
92
|
+
routes: { ...defaultConfig.routes, ...(config.routes || {}) },
|
|
93
|
+
passwordPolicy: { ...defaultConfig.passwordPolicy, ...(config.passwordPolicy || {}) },
|
|
94
|
+
social: { ...defaultConfig.social, ...(config.social || {}) },
|
|
95
|
+
limits: { ...(config.limits || {}) },
|
|
96
|
+
features: { ...defaultConfig.features, ...(config.features || {}) },
|
|
97
|
+
cors: { ...defaultConfig.cors, ...(config.cors || {}) },
|
|
98
|
+
session: { ...defaultConfig.session, ...(config.session || {}) }
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
finalConfig.env = { ...(defaultConfig.env || {}), ...(config.env || process.env) };
|
|
102
|
+
const logger = createLogger(finalConfig.logging !== false);
|
|
103
|
+
|
|
104
|
+
const featureBools = {
|
|
105
|
+
emailVerification: config.emailVerification,
|
|
106
|
+
loginAlerts: config.loginAlerts,
|
|
107
|
+
csrf: config.enableCSRF
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
Object.keys(featureBools).forEach((key) => {
|
|
111
|
+
if (typeof featureBools[key] === 'boolean') {
|
|
112
|
+
finalConfig.features[key] = featureBools[key];
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
finalConfig.emailVerification = finalConfig.features.emailVerification;
|
|
117
|
+
finalConfig.loginAlerts = finalConfig.features.loginAlerts;
|
|
118
|
+
finalConfig.enableCSRF = finalConfig.features.csrf;
|
|
119
|
+
|
|
120
|
+
if (finalConfig.features.securityAttempts === false) {
|
|
121
|
+
finalConfig.loginAlerts = false;
|
|
122
|
+
finalConfig.features.loginAlerts = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (finalConfig.env?.JWT_SECRET === 'crafta-auth-dev-secret') {
|
|
126
|
+
logger.logWarn('JWT_SECRET not provided; using development default secret. Set env.JWT_SECRET in production.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const smtpEnabled = !!(
|
|
130
|
+
finalConfig.smtp &&
|
|
131
|
+
finalConfig.smtp.host &&
|
|
132
|
+
finalConfig.smtp.port &&
|
|
133
|
+
finalConfig.smtp.auth &&
|
|
134
|
+
finalConfig.smtp.auth.user &&
|
|
135
|
+
finalConfig.smtp.auth.pass &&
|
|
136
|
+
finalConfig.smtp.from
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (!smtpEnabled && finalConfig.features.emailVerification) {
|
|
140
|
+
logger.logWarn('SMTP not configured; emailVerification auto-disabled.');
|
|
141
|
+
finalConfig.features.emailVerification = false;
|
|
142
|
+
finalConfig.emailVerification = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!smtpEnabled && finalConfig.features.loginAlerts) {
|
|
146
|
+
logger.logWarn('SMTP not configured; loginAlerts auto-disabled.');
|
|
147
|
+
finalConfig.features.loginAlerts = false;
|
|
148
|
+
finalConfig.loginAlerts = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Passwordless requires SMTP for magic link delivery
|
|
152
|
+
if (finalConfig.features.passwordless && !smtpEnabled) {
|
|
153
|
+
logger.logWarn('SMTP not configured; passwordless (magic links) auto-disabled.');
|
|
154
|
+
finalConfig.features.passwordless = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ------------------------------------------
|
|
158
|
+
// MongoDB Connection — support external connections
|
|
159
|
+
// ------------------------------------------
|
|
160
|
+
const connectDB = () => {
|
|
161
|
+
mongoose.connect(finalConfig.mongoUrl)
|
|
162
|
+
.then(() => logger.logSuccess("MongoDB connected"))
|
|
163
|
+
.catch(err => {
|
|
164
|
+
logger.logError(`MongoDB connection error: ${err.message}`);
|
|
165
|
+
setTimeout(connectDB, 5000);
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (finalConfig.mongooseConnection) {
|
|
170
|
+
// Allow host app to pass an existing mongoose connection
|
|
171
|
+
logger.logInfo("Using provided mongoose connection");
|
|
172
|
+
} else if (mongoose.connection.readyState === 0) {
|
|
173
|
+
connectDB();
|
|
174
|
+
mongoose.connection.on('disconnected', () => {
|
|
175
|
+
logger.logWarn("MongoDB disconnected, attempting to reconnect...");
|
|
176
|
+
connectDB();
|
|
177
|
+
});
|
|
178
|
+
} else {
|
|
179
|
+
logger.logInfo("Using existing mongoose connection");
|
|
180
|
+
}
|
|
181
|
+
// ------------------------------------------
|
|
182
|
+
|
|
183
|
+
const auditService = finalConfig.features.auditLogs ? new AuditService() : null;
|
|
184
|
+
const mfaService = new MFAService();
|
|
185
|
+
const sessionService = new SessionService(finalConfig);
|
|
186
|
+
const webhookService = new WebhookService(finalConfig);
|
|
187
|
+
|
|
188
|
+
// Pass shared services into AuthService config so it can log/audit correctly
|
|
189
|
+
const authService = new AuthService({
|
|
190
|
+
...finalConfig,
|
|
191
|
+
auditService,
|
|
192
|
+
mfaService,
|
|
193
|
+
sessionService,
|
|
194
|
+
webhookService
|
|
195
|
+
});
|
|
196
|
+
const roleService = new RoleService();
|
|
197
|
+
const passwordPolicy = new PasswordPolicy(finalConfig.passwordPolicy);
|
|
198
|
+
const passwordlessService = finalConfig.features.passwordless
|
|
199
|
+
? new PasswordlessService({ ...finalConfig, emailService: authService.emailService })
|
|
200
|
+
: null;
|
|
201
|
+
|
|
202
|
+
const { rateLimiter, limiterFor, verifyToken, checkRole, checkOwnershipOrAdmin } = createAuthMiddleware(finalConfig);
|
|
203
|
+
|
|
204
|
+
if (finalConfig.social && finalConfig.social.google) {
|
|
205
|
+
passport.use(new GoogleStrategy(finalConfig.social.google,
|
|
206
|
+
async (accessToken, refreshToken, profile, done) => {
|
|
207
|
+
try {
|
|
208
|
+
const user = await authService.handleSocialLogin('google', profile);
|
|
209
|
+
done(null, user);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
done(err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return function (app) {
|
|
218
|
+
const safeAudit = async (entry) => {
|
|
219
|
+
if (!auditService) return;
|
|
220
|
+
try {
|
|
221
|
+
await auditService.logActivity(entry);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logger.logWarn(`Audit logging failed: ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const safeWebhook = async (event, data) => {
|
|
228
|
+
if (!finalConfig.features.webhooks) return;
|
|
229
|
+
try {
|
|
230
|
+
await webhookService.emit(event, data);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
logger.logWarn(`Webhook delivery failed: ${err.message}`);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ------------------------------------------
|
|
237
|
+
// SESSION TIMEOUT WARNING MIDDLEWARE
|
|
238
|
+
// ------------------------------------------
|
|
239
|
+
const warningThresholdMs = finalConfig.session?.warningThresholdMs || 5 * 60 * 1000;
|
|
240
|
+
app.use((req, res, next) => {
|
|
241
|
+
const authHeader = req.headers.authorization;
|
|
242
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
243
|
+
const token = authHeader.split(' ')[1];
|
|
244
|
+
try {
|
|
245
|
+
const decoded = jwt.decode(token);
|
|
246
|
+
if (decoded && decoded.exp) {
|
|
247
|
+
const timeLeft = decoded.exp * 1000 - Date.now();
|
|
248
|
+
if (timeLeft > 0 && timeLeft < warningThresholdMs) {
|
|
249
|
+
res.setHeader('X-Session-Expires-In', Math.floor(timeLeft / 1000));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
// Ignore decoding errors in middleware
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
next();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
app.use(cookieParser());
|
|
260
|
+
|
|
261
|
+
// CORS — applied before routes
|
|
262
|
+
app.use(corsMiddleware(finalConfig));
|
|
263
|
+
|
|
264
|
+
if (finalConfig.enableCSRF) {
|
|
265
|
+
const csrfProtection = csrf({ cookie: true });
|
|
266
|
+
app.use(csrfProtection);
|
|
267
|
+
|
|
268
|
+
app.get('/csrf-token', (req, res) => {
|
|
269
|
+
res.json({ csrfToken: req.csrfToken() });
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
app.use(passport.initialize());
|
|
274
|
+
app.use(rateLimiter);
|
|
275
|
+
app.use(helmet({
|
|
276
|
+
contentSecurityPolicy: false,
|
|
277
|
+
crossOriginEmbedderPolicy: false,
|
|
278
|
+
}));
|
|
279
|
+
|
|
280
|
+
app.authService = authService;
|
|
281
|
+
app.roleService = roleService;
|
|
282
|
+
app.auditService = auditService;
|
|
283
|
+
app.mfaService = mfaService;
|
|
284
|
+
app.sessionService = sessionService;
|
|
285
|
+
app.webhookService = webhookService;
|
|
286
|
+
app.passwordlessService = passwordlessService;
|
|
287
|
+
app.passwordPolicy = passwordPolicy;
|
|
288
|
+
app.authLogger = logger;
|
|
289
|
+
|
|
290
|
+
// ------------------------------------------
|
|
291
|
+
// HEALTH CHECK
|
|
292
|
+
// ------------------------------------------
|
|
293
|
+
app.get('/auth/health', async (req, res) => {
|
|
294
|
+
const dbState = mongoose.connection.readyState;
|
|
295
|
+
const dbStatus = dbState === 1 ? 'connected' : dbState === 2 ? 'connecting' : 'disconnected';
|
|
296
|
+
|
|
297
|
+
// Get active session count
|
|
298
|
+
let activeSessions = 0;
|
|
299
|
+
if (mongoose.connection.readyState === 1) {
|
|
300
|
+
try {
|
|
301
|
+
activeSessions = await mongoose.connection.db.collection('users').aggregate([
|
|
302
|
+
{ $match: { sessions: { $exists: true, $not: { $size: 0 } } } },
|
|
303
|
+
{ $unwind: '$sessions' },
|
|
304
|
+
{ $match: { 'sessions.createdAt': { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } } },
|
|
305
|
+
{ $group: { _id: '$_id', count: { $sum: 1 } } },
|
|
306
|
+
{ $group: { _id: null, total: { $sum: '$count' } } }
|
|
307
|
+
]).toArray();
|
|
308
|
+
activeSessions = activeSessions[0]?.total || 0;
|
|
309
|
+
} catch (err) {
|
|
310
|
+
logger.logWarn(`Failed to count active sessions: ${err.message}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Get memory usage
|
|
315
|
+
const memoryUsage = process.memoryUsage();
|
|
316
|
+
|
|
317
|
+
// Get uptime
|
|
318
|
+
const uptime = process.uptime();
|
|
319
|
+
|
|
320
|
+
res.json({
|
|
321
|
+
success: true,
|
|
322
|
+
status: 'ok',
|
|
323
|
+
db: dbStatus,
|
|
324
|
+
version: require('../package.json').version,
|
|
325
|
+
uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
|
|
326
|
+
activeSessions,
|
|
327
|
+
memoryUsage: {
|
|
328
|
+
rss: `${Math.round(memoryUsage.rss / 1024 / 1024)}MB`,
|
|
329
|
+
heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`,
|
|
330
|
+
heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)}MB`
|
|
331
|
+
},
|
|
332
|
+
features: {
|
|
333
|
+
emailVerification: finalConfig.features.emailVerification,
|
|
334
|
+
twoFactor: finalConfig.features.twoFactor,
|
|
335
|
+
passwordless: finalConfig.features.passwordless,
|
|
336
|
+
auditLogs: finalConfig.features.auditLogs,
|
|
337
|
+
webhooks: finalConfig.features.webhooks,
|
|
338
|
+
csrf: finalConfig.features.csrf,
|
|
339
|
+
rateLimit: finalConfig.features.rateLimit
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// EMAIL VERIFICATION
|
|
345
|
+
app.get(
|
|
346
|
+
finalConfig.routes.verify,
|
|
347
|
+
async (req, res, next) => {
|
|
348
|
+
try {
|
|
349
|
+
const token = req.query.token;
|
|
350
|
+
const user = await authService.verifyEmail(token);
|
|
351
|
+
|
|
352
|
+
res.json({
|
|
353
|
+
success: true,
|
|
354
|
+
message: 'Email verified successfully',
|
|
355
|
+
userId: user._id
|
|
356
|
+
});
|
|
357
|
+
} catch (err) {
|
|
358
|
+
next(err);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// REGISTER
|
|
364
|
+
app.post(
|
|
365
|
+
finalConfig.routes.register,
|
|
366
|
+
limiterFor('register'),
|
|
367
|
+
validators.registerValidator,
|
|
368
|
+
validators.handleValidation,
|
|
369
|
+
async (req, res, next) => {
|
|
370
|
+
try {
|
|
371
|
+
const { isValid, errors } = passwordPolicy.validate(req.body.password, {
|
|
372
|
+
email: req.body.email,
|
|
373
|
+
name: req.body.name
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (!isValid) {
|
|
377
|
+
throw ApiError.passwordPolicy('Password policy violation', errors);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const user = await authService.register(req.body);
|
|
381
|
+
await safeAudit({
|
|
382
|
+
userId: user._id,
|
|
383
|
+
action: 'register',
|
|
384
|
+
ipAddress: req.ip,
|
|
385
|
+
userAgent: req.headers['user-agent'],
|
|
386
|
+
status: 'success'
|
|
387
|
+
});
|
|
388
|
+
await safeWebhook('user.registered', { userId: user._id, email: user.email });
|
|
389
|
+
|
|
390
|
+
logger.logSuccess('User registered');
|
|
391
|
+
res.status(201).json({ success: true, message: 'Registration successful' });
|
|
392
|
+
} catch (err) {
|
|
393
|
+
logger.logError(`Register failed: ${err.message}`);
|
|
394
|
+
next(err);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// LOGIN
|
|
400
|
+
app.post(
|
|
401
|
+
finalConfig.routes.login,
|
|
402
|
+
limiterFor('login'),
|
|
403
|
+
validators.loginValidator,
|
|
404
|
+
validators.handleValidation,
|
|
405
|
+
async (req, res, next) => {
|
|
406
|
+
try {
|
|
407
|
+
const deviceInfo = { userAgent: req.headers['user-agent'], ip: req.ip };
|
|
408
|
+
const result = await authService.login(req.body.email, req.body.password, deviceInfo);
|
|
409
|
+
|
|
410
|
+
await safeAudit({
|
|
411
|
+
userId: result.user?._id,
|
|
412
|
+
action: 'login',
|
|
413
|
+
ipAddress: req.ip,
|
|
414
|
+
userAgent: req.headers['user-agent'],
|
|
415
|
+
status: 'success'
|
|
416
|
+
});
|
|
417
|
+
await safeWebhook('user.login', { userId: result.user?._id });
|
|
418
|
+
|
|
419
|
+
logger.logSuccess('Login successful');
|
|
420
|
+
res.json({ success: true, ...result });
|
|
421
|
+
} catch (err) {
|
|
422
|
+
await safeAudit({
|
|
423
|
+
action: 'login',
|
|
424
|
+
ipAddress: req.ip,
|
|
425
|
+
userAgent: req.headers['user-agent'],
|
|
426
|
+
status: 'failure',
|
|
427
|
+
details: { error: err.message }
|
|
428
|
+
});
|
|
429
|
+
logger.logError(`Login failed: ${err.message}`);
|
|
430
|
+
next(err);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// 2FA
|
|
436
|
+
if (finalConfig.features.twoFactor !== false) {
|
|
437
|
+
app.post(
|
|
438
|
+
finalConfig.routes.twoFactor,
|
|
439
|
+
limiterFor('2fa'),
|
|
440
|
+
validators.twoFAValidator,
|
|
441
|
+
validators.handleValidation,
|
|
442
|
+
async (req, res, next) => {
|
|
443
|
+
try {
|
|
444
|
+
const result = await authService.verify2FA(req.body.userId, req.body.code);
|
|
445
|
+
await safeAudit({ userId: result.user._id, action: '2fa_verify', status: 'success' });
|
|
446
|
+
logger.logSuccess('2FA verified');
|
|
447
|
+
res.json({ success: true, ...result });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
logger.logError(`2FA failed: ${err.message}`);
|
|
450
|
+
next(err);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// PASSWORDLESS / MAGIC LINK
|
|
457
|
+
if (finalConfig.features.passwordless && passwordlessService) {
|
|
458
|
+
app.post(
|
|
459
|
+
finalConfig.routes.magicLink,
|
|
460
|
+
limiterFor('login'),
|
|
461
|
+
async (req, res, next) => {
|
|
462
|
+
try {
|
|
463
|
+
const email = req.body.email;
|
|
464
|
+
if (!email) throw ApiError.validation('Email is required');
|
|
465
|
+
const baseUrl = finalConfig.baseUrl || `${req.protocol}://${req.get('host')}`;
|
|
466
|
+
await passwordlessService.requestMagicLink(email, baseUrl);
|
|
467
|
+
res.json({ success: true, message: 'Magic link sent if email exists' });
|
|
468
|
+
} catch (err) {
|
|
469
|
+
next(err);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
app.get(
|
|
475
|
+
finalConfig.routes.magicLinkVerify,
|
|
476
|
+
async (req, res, next) => {
|
|
477
|
+
try {
|
|
478
|
+
const token = req.query.token;
|
|
479
|
+
if (!token) throw ApiError.invalidToken('Magic link token is required');
|
|
480
|
+
const user = await passwordlessService.verifyMagicLink(token);
|
|
481
|
+
const tokens = await authService.generateTokens(user);
|
|
482
|
+
await safeAudit({ userId: user._id, action: 'login', status: 'success', details: { method: 'magic_link' } });
|
|
483
|
+
await safeWebhook('user.login', { userId: user._id, method: 'magic_link' });
|
|
484
|
+
res.json({ success: true, ...tokens, user });
|
|
485
|
+
} catch (err) {
|
|
486
|
+
next(err);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// FORGOT PASSWORD
|
|
493
|
+
app.post(
|
|
494
|
+
finalConfig.routes.forgotPassword,
|
|
495
|
+
limiterFor('forgotPassword'),
|
|
496
|
+
validators.forgotPasswordValidator,
|
|
497
|
+
validators.handleValidation,
|
|
498
|
+
async (req, res, next) => {
|
|
499
|
+
try {
|
|
500
|
+
await authService.forgotPassword(req.body.email);
|
|
501
|
+
logger.logInfo('Password reset email triggered');
|
|
502
|
+
res.json({ success: true, message: 'Password reset email sent if user exists' });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
logger.logError(`Forgot password failed: ${err.message}`);
|
|
505
|
+
next(err);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// RESET PASSWORD
|
|
511
|
+
app.post(
|
|
512
|
+
finalConfig.routes.resetPassword,
|
|
513
|
+
validators.resetPasswordValidator,
|
|
514
|
+
validators.handleValidation,
|
|
515
|
+
async (req, res, next) => {
|
|
516
|
+
try {
|
|
517
|
+
await authService.resetPassword(req.body.token, req.body.newPassword);
|
|
518
|
+
await safeAudit({ action: 'password_reset', status: 'success' });
|
|
519
|
+
await safeWebhook('user.password_changed', { token: req.body.token });
|
|
520
|
+
logger.logSuccess('Password reset completed');
|
|
521
|
+
res.json({ success: true, message: 'Password reset successful' });
|
|
522
|
+
} catch (err) {
|
|
523
|
+
logger.logError(`Reset password failed: ${err.message}`);
|
|
524
|
+
next(err);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
// REFRESH TOKEN
|
|
530
|
+
app.post(
|
|
531
|
+
finalConfig.routes.refreshToken,
|
|
532
|
+
limiterFor('refreshToken'),
|
|
533
|
+
validators.refreshTokenValidator,
|
|
534
|
+
validators.handleValidation,
|
|
535
|
+
async (req, res, next) => {
|
|
536
|
+
try {
|
|
537
|
+
const result = await authService.refreshToken(req.body.refreshToken);
|
|
538
|
+
logger.logSuccess('Token refreshed');
|
|
539
|
+
res.json({ success: true, ...result });
|
|
540
|
+
} catch (err) {
|
|
541
|
+
logger.logError(`Refresh token failed: ${err.message}`);
|
|
542
|
+
next(err);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// LOGOUT
|
|
548
|
+
app.post(
|
|
549
|
+
'/logout',
|
|
550
|
+
verifyToken,
|
|
551
|
+
async (req, res, next) => {
|
|
552
|
+
try {
|
|
553
|
+
await authService.logout(req.user.id, req.body.sessionId, req.body.refreshToken);
|
|
554
|
+
await safeAudit({ userId: req.user.id, action: 'logout', status: 'success' });
|
|
555
|
+
await safeWebhook('user.logout', { userId: req.user.id });
|
|
556
|
+
res.json({ success: true, message: 'Logged out successfully' });
|
|
557
|
+
} catch (err) {
|
|
558
|
+
next(err);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// LOGOUT ALL DEVICES
|
|
564
|
+
app.post(
|
|
565
|
+
'/logout-all',
|
|
566
|
+
verifyToken,
|
|
567
|
+
async (req, res, next) => {
|
|
568
|
+
try {
|
|
569
|
+
const User = require('./models/user');
|
|
570
|
+
const user = await User.findById(req.user.id);
|
|
571
|
+
if (!user) throw ApiError.notFound('User not found');
|
|
572
|
+
await sessionService.revokeAllSessions(user);
|
|
573
|
+
await user.revokeAllRefreshTokens();
|
|
574
|
+
await safeAudit({ userId: req.user.id, action: 'logout', status: 'success', details: { all: true } });
|
|
575
|
+
res.json({ success: true, message: 'All sessions revoked' });
|
|
576
|
+
} catch (err) {
|
|
577
|
+
next(err);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// UPDATE PROFILE
|
|
583
|
+
app.put(
|
|
584
|
+
finalConfig.routes.profile,
|
|
585
|
+
verifyToken,
|
|
586
|
+
checkOwnershipOrAdmin((req) => req.user.id),
|
|
587
|
+
validators.profileUpdateValidator,
|
|
588
|
+
validators.handleValidation,
|
|
589
|
+
async (req, res, next) => {
|
|
590
|
+
try {
|
|
591
|
+
const user = await authService.updateProfile(req.user.id, req.body);
|
|
592
|
+
await safeAudit({ userId: user._id, action: 'profile_update', status: 'success' });
|
|
593
|
+
logger.logSuccess('Profile updated');
|
|
594
|
+
res.json({ success: true, user });
|
|
595
|
+
} catch (err) {
|
|
596
|
+
logger.logError(`Profile update failed: ${err.message}`);
|
|
597
|
+
next(err);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
// CREATE ROLE (ADMIN)
|
|
603
|
+
app.post('/roles', verifyToken, checkRole(['admin']), async (req, res, next) => {
|
|
604
|
+
try {
|
|
605
|
+
const role = await roleService.createRole(req.body);
|
|
606
|
+
res.status(201).json({ success: true, role });
|
|
607
|
+
} catch (err) {
|
|
608
|
+
next(err);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// GOOGLE OAUTH
|
|
613
|
+
if (finalConfig.social && finalConfig.social.google) {
|
|
614
|
+
app.get('/auth/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
|
|
615
|
+
|
|
616
|
+
app.get('/auth/google/callback',
|
|
617
|
+
passport.authenticate('google', { session: false }),
|
|
618
|
+
async (req, res, next) => {
|
|
619
|
+
try {
|
|
620
|
+
const tokens = await authService.generateTokens(req.user);
|
|
621
|
+
await safeAudit({
|
|
622
|
+
userId: req.user._id,
|
|
623
|
+
action: 'social_login',
|
|
624
|
+
status: 'success',
|
|
625
|
+
details: { provider: 'google' }
|
|
626
|
+
});
|
|
627
|
+
await safeWebhook('user.login', { userId: req.user._id, method: 'social', provider: 'google' });
|
|
628
|
+
logger.logSuccess('Google OAuth success');
|
|
629
|
+
res.json({ success: true, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, user: req.user });
|
|
630
|
+
} catch (err) {
|
|
631
|
+
logger.logError(`Google OAuth failed: ${err.message}`);
|
|
632
|
+
next(err);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ------------------------------------------
|
|
638
|
+
// STANDARDIZED ERROR HANDLER
|
|
639
|
+
// ------------------------------------------
|
|
640
|
+
app.use((err, req, res, next) => {
|
|
641
|
+
// Normalize any error into our ApiError shape
|
|
642
|
+
const apiErr = ApiError.normalize(err);
|
|
643
|
+
|
|
644
|
+
// Log stack only in development
|
|
645
|
+
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined) {
|
|
646
|
+
logger.logError(`[${apiErr.code}] ${apiErr.message}`);
|
|
647
|
+
} else {
|
|
648
|
+
// In production, only log server errors with stack
|
|
649
|
+
if (apiErr.status >= 500) {
|
|
650
|
+
logger.logError(`[${apiErr.code}] ${apiErr.message}`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const response = {
|
|
655
|
+
success: false,
|
|
656
|
+
code: apiErr.code,
|
|
657
|
+
error: apiErr.message
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
if (apiErr.details) {
|
|
661
|
+
response.details = apiErr.details;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
res.status(apiErr.status).json(response);
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
module.exports = { auth, ApiError, getPasswordStrength, runAdaptiveTests, inferFeatureFlags, testCatalog };
|