@nabhiraj/nab-auth 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,516 @@
1
+ import argon2 from "argon2";
2
+ import { parseCookie } from "cookie";
3
+ import { normalizeEmail } from "../repositories/userRepository.js";
4
+ import {
5
+ generateNumericVerificationCode,
6
+ hashVerificationCode,
7
+ verifyVerificationCode
8
+ } from "../services/verificationCodeService.js";
9
+
10
+ const dummyPasswordHash =
11
+ "$argon2id$v=19$m=19456,p=1,t=2$4q0Ld3WLtjFp/T4xsM65OA$6fForJ99UdqrscIWMKksrwr/rNpl/bZ3qZc8v/p1YlI";
12
+
13
+ export function createAuthController({
14
+ config,
15
+ repository,
16
+ tokenService,
17
+ payloadFactory,
18
+ options = {}
19
+ }) {
20
+ if (typeof payloadFactory !== "function") {
21
+ throw new TypeError("createAuthController expects a payload function");
22
+ }
23
+
24
+ const { sendRegistrationCode, sendTwoFactorCode } = options;
25
+ validateCallback(sendRegistrationCode, "sendRegistrationCode");
26
+ validateCallback(sendTwoFactorCode, "sendTwoFactorCode");
27
+
28
+ const findTargetUser = async (body) => {
29
+ const userId = body?.user_id;
30
+ const email = body?.email;
31
+
32
+ if (
33
+ (userId !== undefined && email !== undefined) ||
34
+ (userId === undefined && email === undefined)
35
+ ) {
36
+ return { error: "Provide exactly one of user_id or email" };
37
+ }
38
+
39
+ if (userId !== undefined) {
40
+ if (typeof userId !== "string" || !userId) {
41
+ return { error: "user_id must be a non-empty string" };
42
+ }
43
+
44
+ return { user: await repository.findUserByUserId(userId) };
45
+ }
46
+
47
+ if (typeof email !== "string" || !email.trim()) {
48
+ return { error: "email must be a non-empty string" };
49
+ }
50
+
51
+ return { user: await repository.findUserByEmail(normalizeEmail(email)) };
52
+ };
53
+
54
+ const normalizeRoles = (roles, allowEmpty = false) => {
55
+ if (!Array.isArray(roles) || (!allowEmpty && roles.length === 0)) {
56
+ return { error: "roles must be an array of strings" };
57
+ }
58
+
59
+ const normalizedRoles = roles.map((role) =>
60
+ typeof role === "string" ? role.trim() : role
61
+ );
62
+
63
+ if (normalizedRoles.some((role) => typeof role !== "string" || !role)) {
64
+ return { error: "roles must be an array of strings" };
65
+ }
66
+
67
+ return { roles: [...new Set(normalizedRoles)] };
68
+ };
69
+
70
+ const roleResponse = (user) => ({
71
+ user_id: user.user_id,
72
+ email: user.email,
73
+ nab_role: user.nab_role
74
+ });
75
+
76
+ async function createPotentialUser(req, res, next) {
77
+ try {
78
+ const email = req.body?.email;
79
+
80
+ if (typeof email !== "string" || !email.trim()) {
81
+ return res.status(400).json({ message: "email is required" });
82
+ }
83
+
84
+ const normalizedEmail = normalizeEmail(email);
85
+
86
+ if (await repository.findUserByEmail(normalizedEmail)) {
87
+ await repository.deletePotentialUserByEmail(normalizedEmail);
88
+ return res.status(409).json({ message: "User already exists" });
89
+ }
90
+
91
+ requireDeliveryCallback(
92
+ sendRegistrationCode,
93
+ "sendRegistrationCode",
94
+ config.codeDelivery
95
+ );
96
+
97
+ const verificationCode = generateNumericVerificationCode(
98
+ config.registration.codeDigits
99
+ );
100
+ const codeGeneratedAt = new Date();
101
+ const codeExpiresAt = new Date(
102
+ codeGeneratedAt.getTime() + config.registration.codeTtlMs
103
+ );
104
+ const verificationCodeHash = await hashVerificationCode(verificationCode);
105
+ const potentialUserCreated = await repository.upsertPotentialUser(
106
+ normalizedEmail,
107
+ verificationCodeHash,
108
+ codeGeneratedAt,
109
+ codeExpiresAt
110
+ );
111
+
112
+ if (!potentialUserCreated) {
113
+ return res.status(409).json({ message: "User already exists" });
114
+ }
115
+
116
+ if (sendRegistrationCode) {
117
+ await sendRegistrationCode({
118
+ email: normalizedEmail,
119
+ verificationCode,
120
+ codeGeneratedAt,
121
+ codeExpiresAt
122
+ });
123
+ return res.status(202).json({ message: "Verification code sent" });
124
+ }
125
+
126
+ return res.status(201).json({
127
+ email: normalizedEmail,
128
+ verificationCode,
129
+ expiresAt: codeExpiresAt
130
+ });
131
+ } catch (error) {
132
+ next(error);
133
+ }
134
+ }
135
+
136
+ async function registerUser(req, res, next) {
137
+ try {
138
+ const email = req.body?.email;
139
+ const password = req.body?.password;
140
+
141
+ if (!isNonEmptyString(email) || !isNonEmptyString(password)) {
142
+ return res.status(400).json({ message: "email and password are required" });
143
+ }
144
+
145
+ const normalizedEmail = normalizeEmail(email);
146
+ if (await repository.findUserByEmail(normalizedEmail)) {
147
+ return res.status(409).json({ message: "User already exists" });
148
+ }
149
+
150
+ if (config.registration.verificationRequired) {
151
+ const verificationCode = req.body?.verificationCode;
152
+
153
+ if (!isNonEmptyString(verificationCode)) {
154
+ return res.status(400).json({ message: "verificationCode is required" });
155
+ }
156
+
157
+ const potentialUser = await repository.findPotentialUserByEmail(
158
+ normalizedEmail
159
+ );
160
+ const codeMatches = await codeMatchesRecord(
161
+ verificationCode,
162
+ potentialUser?.verificationCodeHash,
163
+ potentialUser?.codeExpiresAt
164
+ );
165
+
166
+ if (!codeMatches) {
167
+ return res.status(400).json({
168
+ message: "Invalid or expired verification code"
169
+ });
170
+ }
171
+ }
172
+
173
+ try {
174
+ const userId = await repository.insertUser(
175
+ normalizedEmail,
176
+ await hashPassword(password, config.passwordHash)
177
+ );
178
+
179
+ if (config.registration.verificationRequired) {
180
+ await repository.deletePotentialUserByEmail(normalizedEmail);
181
+ }
182
+
183
+ return res.status(201).json({ user_id: userId, email: normalizedEmail });
184
+ } catch (error) {
185
+ if (error.code === 11000) {
186
+ return res.status(409).json({ message: "User already exists" });
187
+ }
188
+ throw error;
189
+ }
190
+ } catch (error) {
191
+ next(error);
192
+ }
193
+ }
194
+
195
+ async function generateTwoFactorCode(req, res, next) {
196
+ try {
197
+ if (!config.twoFactor.required) {
198
+ return res.status(400).json({ message: "Two-factor authentication is disabled" });
199
+ }
200
+
201
+ const user = await authenticatePrimaryCredentials(req.body);
202
+ if (!user) {
203
+ return res.status(401).json({ message: "Invalid email or password" });
204
+ }
205
+
206
+ requireDeliveryCallback(
207
+ sendTwoFactorCode,
208
+ "sendTwoFactorCode",
209
+ config.codeDelivery
210
+ );
211
+
212
+ const verificationCode = generateNumericVerificationCode(
213
+ config.twoFactor.codeDigits
214
+ );
215
+ const codeGeneratedAt = new Date();
216
+ const codeExpiresAt = new Date(
217
+ codeGeneratedAt.getTime() + config.twoFactor.codeTtlMs
218
+ );
219
+ await repository.updateUserTwoFactorCode(
220
+ user.user_id,
221
+ await hashVerificationCode(verificationCode),
222
+ codeGeneratedAt,
223
+ codeExpiresAt
224
+ );
225
+
226
+ if (sendTwoFactorCode) {
227
+ await sendTwoFactorCode({
228
+ email: user.email,
229
+ verificationCode,
230
+ codeGeneratedAt,
231
+ codeExpiresAt
232
+ });
233
+ return res.status(202).json({ message: "Two-factor code sent" });
234
+ }
235
+
236
+ return res.status(201).json({
237
+ email: user.email,
238
+ verificationCode,
239
+ expiresAt: codeExpiresAt
240
+ });
241
+ } catch (error) {
242
+ next(error);
243
+ }
244
+ }
245
+
246
+ async function issueToken(req, res, next) {
247
+ try {
248
+ const user = await authenticatePrimaryCredentials(req.body);
249
+ if (!user) {
250
+ return res.status(401).json({ message: "Invalid email or password" });
251
+ }
252
+
253
+ if (config.twoFactor.required) {
254
+ const twoFactorCode = req.body?.twoFactorCode;
255
+ const codeMatches = await codeMatchesRecord(
256
+ twoFactorCode,
257
+ user.twoFactorCodeHash,
258
+ user.twoFactorCodeExpiresAt
259
+ );
260
+
261
+ if (!codeMatches) {
262
+ return res.status(401).json({
263
+ message: twoFactorCode
264
+ ? "Invalid or expired two-factor code"
265
+ : "Two-factor code is required"
266
+ });
267
+ }
268
+ }
269
+
270
+ const payload = await payloadFactory(user);
271
+ const tokens = await tokenService.createTokenPair(payload, user.user_id);
272
+
273
+ if (config.twoFactor.required) {
274
+ const codeConsumed = await repository.consumeUserTwoFactorCode(
275
+ user.user_id,
276
+ user.twoFactorCodeHash
277
+ );
278
+
279
+ if (!codeConsumed) {
280
+ return res.status(401).json({
281
+ message: "Invalid or already used two-factor code"
282
+ });
283
+ }
284
+ }
285
+
286
+ setRefreshCookie(res, tokens.refreshToken, config.refreshCookie);
287
+ return res.json({ accessToken: tokens.accessToken });
288
+ } catch (error) {
289
+ next(error);
290
+ }
291
+ }
292
+
293
+ async function refreshToken(req, res, next) {
294
+ try {
295
+ const refreshTokenFromBody = req.body?.refresh_token;
296
+ const cookies = parseCookie(req.headers.cookie || "");
297
+ const refreshToken =
298
+ isNonEmptyString(refreshTokenFromBody)
299
+ ? refreshTokenFromBody
300
+ : cookies[config.refreshCookie.name];
301
+
302
+ if (!refreshToken) {
303
+ return res.status(401).json({ message: "Refresh token is required" });
304
+ }
305
+
306
+ let tokenPayload;
307
+ try {
308
+ tokenPayload = await tokenService.verifyRefreshToken(refreshToken);
309
+ } catch {
310
+ return res.status(401).json({ message: "Invalid or expired refresh token" });
311
+ }
312
+
313
+ const user = await repository.findUserByUserId(tokenPayload.sub);
314
+ if (!user) {
315
+ return res.status(401).json({ message: "Invalid or expired refresh token" });
316
+ }
317
+
318
+ const payload = await payloadFactory(user);
319
+ const tokens = await tokenService.createTokenPair(payload, user.user_id);
320
+ setRefreshCookie(res, tokens.refreshToken, config.refreshCookie);
321
+ return res.json({ accessToken: tokens.accessToken });
322
+ } catch (error) {
323
+ next(error);
324
+ }
325
+ }
326
+
327
+ async function changePassword(req, res, next) {
328
+ try {
329
+ const newPassword = req.body?.newPassword;
330
+ const requestedUserId = req.body?.user_id;
331
+ const requestedEmail = req.body?.email;
332
+
333
+ if (
334
+ !isNonEmptyString(newPassword) ||
335
+ (requestedUserId !== undefined && !isNonEmptyString(requestedUserId)) ||
336
+ (requestedEmail !== undefined && !isNonEmptyString(requestedEmail)) ||
337
+ (requestedUserId !== undefined && requestedEmail !== undefined)
338
+ ) {
339
+ return res.status(400).json({
340
+ message: "newPassword is required; provide either user_id or email, not both"
341
+ });
342
+ }
343
+
344
+ const authenticatedUserId = req.auth?.sub;
345
+ if (!isNonEmptyString(authenticatedUserId)) {
346
+ return res.status(401).json({ message: "Invalid access token" });
347
+ }
348
+
349
+ const targetUser = requestedUserId
350
+ ? await repository.findUserByUserId(requestedUserId)
351
+ : requestedEmail
352
+ ? await repository.findUserByEmail(normalizeEmail(requestedEmail))
353
+ : await repository.findUserByUserId(authenticatedUserId);
354
+
355
+ if (!targetUser) {
356
+ return res.status(404).json({ message: "User not found" });
357
+ }
358
+
359
+ if (
360
+ targetUser.user_id !== authenticatedUserId &&
361
+ (!req.auth?.nab_role?.includes(config.authorization.adminRole) ||
362
+ !config.authorization.nabAdminCanChangeOtherUsersPasswords)
363
+ ) {
364
+ return res.status(403).json({
365
+ message: "Nab admin permission is required to change another user's password"
366
+ });
367
+ }
368
+
369
+ const userUpdated = await repository.updateUserPassword(
370
+ targetUser.user_id,
371
+ await hashPassword(newPassword, config.passwordHash)
372
+ );
373
+
374
+ return userUpdated
375
+ ? res.status(204).send()
376
+ : res.status(404).json({ message: "User not found" });
377
+ } catch (error) {
378
+ next(error);
379
+ }
380
+ }
381
+
382
+ async function addRoles(req, res, next) {
383
+ try {
384
+ const normalized = normalizeRoles(req.body?.roles);
385
+ if (normalized.error) return res.status(400).json({ message: normalized.error });
386
+
387
+ const target = await findTargetUser(req.body);
388
+ if (target.error) return res.status(400).json({ message: target.error });
389
+ if (!target.user) return res.status(404).json({ message: "User not found" });
390
+
391
+ return res.json(
392
+ roleResponse(await repository.addUserRoles(target.user.user_id, normalized.roles))
393
+ );
394
+ } catch (error) {
395
+ next(error);
396
+ }
397
+ }
398
+
399
+ async function removeRole(req, res, next) {
400
+ try {
401
+ const role = req.body?.role;
402
+ if (!isNonEmptyString(role)) {
403
+ return res.status(400).json({ message: "role must be a non-empty string" });
404
+ }
405
+
406
+ const target = await findTargetUser(req.body);
407
+ if (target.error) return res.status(400).json({ message: target.error });
408
+ if (!target.user) return res.status(404).json({ message: "User not found" });
409
+
410
+ return res.json(
411
+ roleResponse(await repository.removeUserRole(target.user.user_id, role.trim()))
412
+ );
413
+ } catch (error) {
414
+ next(error);
415
+ }
416
+ }
417
+
418
+ async function replaceRoles(req, res, next) {
419
+ try {
420
+ const normalized = normalizeRoles(req.body?.roles, true);
421
+ if (normalized.error) return res.status(400).json({ message: normalized.error });
422
+
423
+ const target = await findTargetUser(req.body);
424
+ if (target.error) return res.status(400).json({ message: target.error });
425
+ if (!target.user) return res.status(404).json({ message: "User not found" });
426
+
427
+ return res.json(
428
+ roleResponse(
429
+ await repository.replaceUserRoles(target.user.user_id, normalized.roles)
430
+ )
431
+ );
432
+ } catch (error) {
433
+ next(error);
434
+ }
435
+ }
436
+
437
+ async function authenticatePrimaryCredentials(body) {
438
+ const email = body?.email;
439
+ const password = body?.password;
440
+
441
+ if (!isNonEmptyString(email) || !isNonEmptyString(password)) {
442
+ return null;
443
+ }
444
+
445
+ const user = await repository.findUserByEmail(normalizeEmail(email));
446
+ const passwordMatches = await argon2.verify(
447
+ user?.passwordHash || dummyPasswordHash,
448
+ password
449
+ );
450
+ return user && passwordMatches ? user : null;
451
+ }
452
+
453
+ return {
454
+ createPotentialUser,
455
+ registerUser,
456
+ generateTwoFactorCode,
457
+ issueToken,
458
+ refreshToken,
459
+ changePassword,
460
+ addRoles,
461
+ removeRole,
462
+ replaceRoles
463
+ };
464
+ }
465
+
466
+ async function codeMatchesRecord(code, codeHash, expiresAt) {
467
+ if (
468
+ !isNonEmptyString(code) ||
469
+ !isNonEmptyString(codeHash) ||
470
+ !(expiresAt instanceof Date) ||
471
+ expiresAt.getTime() <= Date.now()
472
+ ) {
473
+ return false;
474
+ }
475
+
476
+ try {
477
+ return await verifyVerificationCode(code, codeHash);
478
+ } catch {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ function setRefreshCookie(res, refreshToken, cookieConfig) {
484
+ res.cookie(cookieConfig.name, refreshToken, {
485
+ httpOnly: cookieConfig.httpOnly,
486
+ secure: cookieConfig.secure,
487
+ sameSite: cookieConfig.sameSite,
488
+ path: cookieConfig.path,
489
+ maxAge: cookieConfig.maxAgeMs
490
+ });
491
+ }
492
+
493
+ async function hashPassword(password, passwordHashConfig) {
494
+ return argon2.hash(password, {
495
+ type: argon2.argon2id,
496
+ ...passwordHashConfig
497
+ });
498
+ }
499
+
500
+ function validateCallback(callback, name) {
501
+ if (callback !== undefined && typeof callback !== "function") {
502
+ throw new TypeError(`${name} must be a function`);
503
+ }
504
+ }
505
+
506
+ function requireDeliveryCallback(callback, name, codeDeliveryConfig) {
507
+ if (!callback && !codeDeliveryConfig.allowCodeInResponseWithoutSender) {
508
+ throw new Error(
509
+ `${name} must be configured when code responses are disabled`
510
+ );
511
+ }
512
+ }
513
+
514
+ function isNonEmptyString(value) {
515
+ return typeof value === "string" && !!value.trim();
516
+ }
@@ -0,0 +1,43 @@
1
+ import { createAuthConfig } from "./config/createAuthConfig.js";
2
+ import { createAuthController } from "./controllers/authController.js";
3
+ import { createJwtMiddleware } from "./middleware/jwtMiddleware.js";
4
+ import { createUserRepository } from "./repositories/userRepository.js";
5
+ import { createAuthRouter } from "./routes/authRoutes.js";
6
+ import { createJwtKeyProvider } from "./services/jwtKeyProvider.js";
7
+ import { createTokenService } from "./services/tokenService.js";
8
+
9
+ export function createAuthenticationLibrary(configOverrides = {}) {
10
+ const config = createAuthConfig(configOverrides);
11
+ const keyProvider = createJwtKeyProvider(config.jwt);
12
+ const repository = createUserRepository({
13
+ mongo: config.mongo,
14
+ defaultAdmin: config.defaultAdmin,
15
+ passwordHash: config.passwordHash
16
+ });
17
+ const tokenService = createTokenService({ jwt: config.jwt, keyProvider });
18
+ const middleware = createJwtMiddleware({
19
+ jwt: config.jwt,
20
+ keyProvider,
21
+ authorization: config.authorization
22
+ });
23
+
24
+ return {
25
+ config,
26
+ connectToDatabase: repository.connectToDatabase,
27
+ closeDatabase: repository.closeDatabase,
28
+ requireJwt: middleware.requireJwt,
29
+ requireNabAdmin: middleware.requireNabAdmin,
30
+ jwtErrorHandler: middleware.jwtErrorHandler,
31
+ createAuthRouter(payloadFactory, options = {}) {
32
+ const controller = createAuthController({
33
+ config,
34
+ repository,
35
+ tokenService,
36
+ payloadFactory,
37
+ options
38
+ });
39
+
40
+ return createAuthRouter({ controller, middleware, config });
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,3 @@
1
+ export { createAuthenticationLibrary } from "./createAuthenticationLibrary.js";
2
+ export { defaultAuthConfig } from "./config/defaultAuthConfig.js";
3
+ export { createFileJwtKeyProvider } from "./services/jwtKeyProvider.js";
@@ -0,0 +1,35 @@
1
+ import { expressjwt } from "express-jwt";
2
+
3
+ export function createJwtMiddleware({ jwt: jwtConfig, keyProvider, authorization }) {
4
+ const requireJwt = expressjwt({
5
+ secret: async (req, token) => {
6
+ if (token?.header?.alg !== jwtConfig.algorithm) {
7
+ throw new Error("Invalid JWT algorithm");
8
+ }
9
+
10
+ return keyProvider.getPublicKey(token.header.kid);
11
+ },
12
+ algorithms: [jwtConfig.algorithm],
13
+ issuer: jwtConfig.issuer,
14
+ audience: jwtConfig.audience,
15
+ isRevoked: async (req, token) => token?.payload?.tokenType !== "access"
16
+ });
17
+
18
+ function requireNabAdmin(req, res, next) {
19
+ if (!req.auth?.nab_role?.includes(authorization.adminRole)) {
20
+ return res.status(403).json({ message: "Nab admin role is required" });
21
+ }
22
+
23
+ next();
24
+ }
25
+
26
+ function jwtErrorHandler(error, req, res, next) {
27
+ if (error.name === "UnauthorizedError") {
28
+ return res.status(401).json({ message: "Invalid or missing JWT" });
29
+ }
30
+
31
+ next(error);
32
+ }
33
+
34
+ return { requireJwt, requireNabAdmin, jwtErrorHandler };
35
+ }