@nabhiraj/nab-auth 0.1.1 → 1.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.
package/README.md CHANGED
@@ -1,33 +1,39 @@
1
1
  # nab-auth
2
2
 
3
+ <img src="./nab-auth-logo.png" alt="nab-auth logo" width="180">
4
+
3
5
  [![license](https://img.shields.io/npm/l/@nabhiraj/nab-auth.svg)](LICENSE)
4
6
 
5
- Configurable authentication for Express and MongoDB. `nab-auth` provides
6
- Argon2id password hashing, RS256 JWTs, refresh-token rotation, roles,
7
- registration verification, and optional two-factor authentication.
7
+ `@nabhiraj/nab-auth` is a configurable Express authentication library. It
8
+ provides Argon2id password hashing, RS256 access and refresh tokens, roles,
9
+ registration verification, and optional two-factor authentication. Storage is
10
+ provided by an explicit adapter: MongoDB/Atlas support is bundled, while other
11
+ databases can be added by implementing the auth-store contract.
8
12
 
9
- The library does not load environment variables or send email/SMS itself. The
10
- host application owns configuration and code delivery.
13
+ The host application owns environment loading, database credentials, JWT key
14
+ locations, and email/SMS delivery. The library does not load `.env` files or
15
+ send messages itself.
11
16
 
12
17
  ## Features
13
18
 
14
- - Email/password registration and login with Argon2id password hashes
15
- - Short-lived RS256 access tokens and rotating refresh-token cookies
16
- - Public-key rotation using JWT `kid` headers
17
- - Role claims and administrator-protected role management
19
+ - Email/password registration and login with Argon2id hashes
20
+ - RS256 access tokens and refresh-token cookies
21
+ - JWT `kid` support for public-key rotation
22
+ - Role claims and administrator-protected role endpoints
18
23
  - Optional registration-code verification and two-factor authentication
19
- - MongoDB/Atlas storage with unique and TTL indexes created automatically
20
- - Verifier-only mode for services that only need to validate access tokens
24
+ - Explicit database adapters; MongoDB/Atlas adapter included
25
+ - Verifier-only mode for services that only validate access tokens
21
26
 
22
27
  ## Requirements
23
28
 
24
- - Node.js 20 or later
25
- - Express 5
26
- - MongoDB or MongoDB Atlas
27
- - An RSA key pair for RS256 JWT signing
29
+ - Node.js 20 or newer
30
+ - Express 5 (peer dependency)
31
+ - An RSA key pair for RS256 signing and verification
32
+ - A database adapter for services that register users, issue tokens, or use
33
+ the authentication router
28
34
 
29
- `argon2` is a native dependency. Most supported systems use a prebuilt binary;
30
- unusual platforms may need standard Node.js native build tools.
35
+ `argon2` is a native dependency. Most platforms use a prebuilt binary; unusual
36
+ platforms may require standard Node.js native build tools.
31
37
 
32
38
  ## Installation
33
39
 
@@ -35,26 +41,40 @@ unusual platforms may need standard Node.js native build tools.
35
41
  npm install @nabhiraj/nab-auth express
36
42
  ```
37
43
 
38
- ## Quick start
44
+ ## Quick start: MongoDB
45
+
46
+ The application loads its own environment configuration and passes the values
47
+ to the adapter. The library does not require a particular `.env` package or
48
+ variable naming convention.
39
49
 
40
50
  ```js
51
+ import "dotenv/config";
41
52
  import express from "express";
42
- import { createAuthenticationLibrary } from "@nabhiraj/nab-auth";
53
+ import {
54
+ createAuthenticationLibrary,
55
+ createMongoAuthStore
56
+ } from "@nabhiraj/nab-auth";
43
57
 
44
58
  const app = express();
45
59
  app.use(express.json());
46
60
 
47
61
  const auth = createAuthenticationLibrary({
48
- mongo: {
49
- uri: process.env.MY_AUTH_MONGO_URI,
50
- databaseName: "my_auth_database"
62
+ database: {
63
+ adapter: createMongoAuthStore,
64
+ adapterConfig: {
65
+ uri: process.env.MONGODB_URI,
66
+ databaseName: process.env.MONGODB_DB_NAME,
67
+ usersCollectionName: process.env.MONGODB_USERS_COLLECTION || "users",
68
+ potentialUsersCollectionName:
69
+ process.env.MONGODB_POTENTIAL_USERS_COLLECTION || "potential_users"
70
+ }
51
71
  },
52
72
  jwt: {
53
- issuer: "my-service",
54
- audience: "my-client",
73
+ issuer: process.env.JWT_ISSUER,
74
+ audience: process.env.JWT_AUDIENCE,
55
75
  keys: {
56
- privateKeyPath: process.env.MY_JWT_PRIVATE_KEY_PATH,
57
- publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
76
+ privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH,
77
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
58
78
  }
59
79
  },
60
80
  defaultAdmin: { enabled: false }
@@ -69,10 +89,10 @@ app.use(
69
89
  "/auth",
70
90
  auth.createAuthRouter(createTokenPayload, {
71
91
  sendRegistrationCode: async ({ email, verificationCode }) => {
72
- await sendEmail(email, `Your verification code is ${verificationCode}`);
92
+ await emailProvider.send(email, `Your verification code is ${verificationCode}`);
73
93
  },
74
94
  sendTwoFactorCode: async ({ email, verificationCode }) => {
75
- await sendEmail(email, `Your 2FA code is ${verificationCode}`);
95
+ await emailProvider.send(email, `Your 2FA code is ${verificationCode}`);
76
96
  }
77
97
  })
78
98
  );
@@ -86,49 +106,139 @@ await auth.connectToDatabase();
86
106
  app.listen(3000);
87
107
  ```
88
108
 
89
- `sendEmail` is an application function you provide. Configure a code-delivery
90
- callback only when registration verification or two-factor authentication is
91
- enabled.
109
+ The order matters: call `connectToDatabase()` before receiving requests, and
110
+ mount `jwtErrorHandler` after routes that use `requireJwt`.
92
111
 
93
112
  ## Core API
94
113
 
95
114
  ```js
96
115
  import {
116
+ assertAuthStore,
117
+ authStoreMethods,
97
118
  createAuthenticationLibrary,
98
119
  createFileJwtKeyProvider,
120
+ createMongoAuthStore,
99
121
  defaultAuthConfig
100
122
  } from "@nabhiraj/nab-auth";
101
123
  ```
102
124
 
103
- `createAuthenticationLibrary(config)` creates isolated state: its MongoDB
104
- client, JWT-key cache, middleware, and router do not share state with another
125
+ `createAuthenticationLibrary(config)` creates isolated state. Its adapter,
126
+ JWT-key cache, middleware, and router are not shared with another library
105
127
  instance.
106
128
 
107
129
  | Member | Purpose |
108
130
  | --- | --- |
109
- | `connectToDatabase()` | Connects to MongoDB and creates configured collections/indexes. |
110
- | `closeDatabase()` | Closes this instance's MongoDB client. |
111
- | `createAuthRouter(payloadFactory, options)` | Creates the Express authentication router. |
112
- | `requireJwt` | Verifies access tokens and sets claims on `req.auth`. |
113
- | `requireNabAdmin` | Requires the configured administrator role. |
114
- | `jwtErrorHandler` | Express JWT error middleware; mount after protected routes. |
115
- | `config` | The merged configuration used by this instance. |
131
+ | `connectToDatabase()` | Creates the configured adapter, connects it, and creates the default admin when enabled. |
132
+ | `closeDatabase()` | Closes the adapter if it was created. |
133
+ | `createAuthRouter(payloadFactory, options)` | Creates the storage-backed Express authentication router. |
134
+ | `requireJwt` | Validates an access token and sets claims on `req.auth`. |
135
+ | `requireNabAdmin` | Requires the configured administrator role after `requireJwt`. |
136
+ | `jwtErrorHandler` | Converts `express-jwt` authentication failures to a `401` response. |
137
+ | `config` | The merged configuration for this library instance. |
138
+
139
+ `payloadFactory(user)` must return an object. Its properties are included in
140
+ new access tokens; the library adds `sub`, JWT metadata, and
141
+ `tokenType: "access"`.
142
+
143
+ ## Database adapters
144
+
145
+ There is no default database. A storage-backed service must configure
146
+ `database.adapter` as a factory function and pass adapter-specific values in
147
+ `database.adapterConfig`.
148
+
149
+ ```js
150
+ const auth = createAuthenticationLibrary({
151
+ database: {
152
+ adapter: createPostgresAuthStore,
153
+ adapterConfig: {
154
+ connectionString: process.env.DATABASE_URL,
155
+ usersTableName: "users",
156
+ pendingUsersTableName: "pending_users"
157
+ }
158
+ }
159
+ });
160
+ ```
161
+
162
+ Import the adapter in the application and pass the function itself. Do not pass
163
+ a source-file path or make the library dynamically load a module.
164
+
165
+ ### MongoDB adapter
166
+
167
+ The package exports `createMongoAuthStore`. Its adapter configuration is:
168
+
169
+ | Field | Required | Description |
170
+ | --- | --- | --- |
171
+ | `uri` | Yes | MongoDB or MongoDB Atlas connection URI. |
172
+ | `databaseName` | Yes | Database used exclusively for auth data. |
173
+ | `usersCollectionName` | Yes | Collection for permanent user records. |
174
+ | `potentialUsersCollectionName` | Yes | Collection for pending registration records. |
175
+
176
+ On `connect()`, the Mongo adapter creates missing collections and indexes:
177
+
178
+ - Unique indexes for `users.email` and `users.user_id`
179
+ - A unique index for pending-user `email`
180
+ - A TTL index on pending-user `expiresAt`
181
+
182
+ It also migrates existing Mongo user records that lack `user_id` or `nab_role`.
183
+
184
+ ### Custom adapter contract
185
+
186
+ An adapter factory receives `adapterConfig` and returns an object that
187
+ implements every method below. `assertAuthStore(store)` validates the shape,
188
+ and `authStoreMethods` exports the method names.
189
+
190
+ | Method | Required behavior |
191
+ | --- | --- |
192
+ | `connect()` | Open the database connection and prepare storage. |
193
+ | `close()` | Release the database connection/resources. |
194
+ | `findUserByEmail(email)` | Return the user record or `null`. |
195
+ | `findUserByUserId(userId)` | Return the user record or `null`. |
196
+ | `createUser(user)` | Persist the user and return `true`; return `false` when its email already exists. |
197
+ | `upsertPotentialUser(email, hash, generatedAt, expiresAt)` | Create/update the pending registration; return `false` when the email is already registered. |
198
+ | `findPotentialUserByEmail(email)` | Return the pending record or `null`. |
199
+ | `deletePotentialUserByEmail(email)` | Delete the pending registration if present. |
200
+ | `updateUserPassword(userId, hash)` | Update the password hash; return whether a user matched. |
201
+ | `updateUserTwoFactorCode(userId, hash, generatedAt, expiresAt)` | Store a new 2FA code hash; return whether a user matched. |
202
+ | `consumeUserTwoFactorCode(userId, hash)` | Atomically consume exactly that unused 2FA hash; return whether it was consumed. |
203
+ | `addUserRoles(userId, roles)` | Add unique roles and return the updated user or `null`. |
204
+ | `removeUserRole(userId, role)` | Remove a role and return the updated user or `null`. |
205
+ | `replaceUserRoles(userId, roles)` | Replace all roles and return the updated user or `null`. |
206
+
207
+ User records returned by an adapter must provide at least:
116
208
 
117
- `payloadFactory(user)` creates application-specific access-token claims. The
118
- library adds standard JWT claims, `sub`, and `tokenType: "access"`.
209
+ ```js
210
+ {
211
+ user_id: "uuid",
212
+ email: "person@example.com",
213
+ passwordHash: "argon2 hash",
214
+ nab_role: ["role"],
215
+ createdAt: new Date()
216
+ }
217
+ ```
218
+
219
+ Pending records must provide `verificationCodeHash` and `codeExpiresAt` as a
220
+ JavaScript `Date`. User records with a pending 2FA challenge use
221
+ `twoFactorCodeHash` and `twoFactorCodeExpiresAt`.
222
+
223
+ The core normalizes email before it invokes the store. Custom adapters should
224
+ preserve that normalized email value and enforce unique email storage.
119
225
 
120
226
  ## Configuration
121
227
 
122
- Every setting is optional unless a feature needs it. Omitted values use
123
- `defaultAuthConfig`.
228
+ All settings use `defaultAuthConfig` when omitted. `database.adapter` is
229
+ required before calling `connectToDatabase()` or `createAuthRouter()`. A
230
+ verifier-only service can omit it.
124
231
 
125
232
  ```js
126
233
  const auth = createAuthenticationLibrary({
127
- mongo: {
128
- uri: process.env.MY_AUTH_MONGO_URI,
129
- databaseName: "auth_db",
130
- usersCollectionName: "users",
131
- potentialUsersCollectionName: "potential_user"
234
+ database: {
235
+ adapter: createMongoAuthStore,
236
+ adapterConfig: {
237
+ uri: process.env.MONGODB_URI,
238
+ databaseName: process.env.MONGODB_DB_NAME,
239
+ usersCollectionName: "users",
240
+ potentialUsersCollectionName: "potential_users"
241
+ }
132
242
  },
133
243
  jwt: {
134
244
  issuer: "my-service",
@@ -138,8 +248,8 @@ const auth = createAuthenticationLibrary({
138
248
  refreshTokenTtl: "7d",
139
249
  keyCacheTtlMs: 15 * 60 * 1000,
140
250
  keys: {
141
- privateKeyPath: process.env.MY_PRIVATE_KEY_PATH,
142
- publicKeyPath: process.env.MY_PUBLIC_KEY_PATH
251
+ privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH,
252
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
143
253
  }
144
254
  },
145
255
  authorization: {
@@ -158,11 +268,6 @@ const auth = createAuthenticationLibrary({
158
268
  codeDigits: 6,
159
269
  codeTtlMs: 10 * 60 * 1000
160
270
  },
161
- passwordHash: {
162
- memoryCost: 19456,
163
- timeCost: 2,
164
- parallelism: 1
165
- },
166
271
  refreshCookie: {
167
272
  name: "refresh_token",
168
273
  httpOnly: true,
@@ -176,57 +281,70 @@ const auth = createAuthenticationLibrary({
176
281
  },
177
282
  defaultAdmin: {
178
283
  enabled: false,
179
- email: "admin@admin.com",
180
- password: "change-this",
284
+ email: "admin@example.com",
285
+ password: "use-a-strong-secret",
181
286
  roles: ["nab_admin"]
182
287
  }
183
288
  });
184
289
  ```
185
290
 
186
- Use `refreshCookie.secure: true` for HTTPS production deployments. It defaults
187
- to `false` only for local HTTP development. `defaultAdmin.enabled` defaults to
188
- `false`; never enable it with a known or weak password.
291
+ `refreshCookie.secure` defaults to `false` for local HTTP development; set it
292
+ to `true` for HTTPS production deployments. `defaultAdmin.enabled` defaults to
293
+ `false`; do not enable it with a known or weak password.
294
+
295
+ ### Registration and authorization policy
296
+
297
+ You can provide an optional password validator for registration:
298
+
299
+ ```js
300
+ registration: {
301
+ validatePassword: (password) => password.length >= 12
302
+ }
303
+ ```
304
+
305
+ The function receives the plaintext password and must return `true` or `false`;
306
+ async functions returning a boolean are also supported. A rejected password
307
+ receives HTTP `400` and is not stored. If omitted, the existing non-empty
308
+ password requirement applies. This setting applies to registration only, not
309
+ password changes or default-admin creation. Validator errors are passed to
310
+ Express error handling; the function must not log or persist passwords.
311
+
312
+ | Setting | Effect |
313
+ | --- | --- |
314
+ | `registration.verificationRequired` | Requires a valid registration code on `POST /register`. |
315
+ | `twoFactor.required` | Requires a valid 2FA code before issuing tokens. |
316
+ | `authorization.userRegistrationRequiresNabAdmin` | Requires a JWT-bearing administrator to register users. |
317
+ | `authorization.passwordChangeRequiresNabAdmin` | Requires a JWT-bearing administrator for all password changes. |
318
+ | `authorization.nabAdminCanChangeOtherUsersPasswords` | Lets an administrator change another user’s password when admin-only password changes are disabled. |
189
319
 
190
320
  ### Code delivery callbacks
191
321
 
322
+ Supply callbacks only if the associated feature is enabled:
323
+
192
324
  ```js
193
325
  auth.createAuthRouter(createTokenPayload, {
194
- sendRegistrationCode: async ({
195
- email,
196
- verificationCode,
197
- codeGeneratedAt,
198
- codeExpiresAt
199
- }) => {
326
+ sendRegistrationCode: async ({ email, verificationCode }) => {
200
327
  await emailProvider.send(email, verificationCode);
201
328
  },
202
- sendTwoFactorCode: async ({
203
- email,
204
- verificationCode,
205
- codeGeneratedAt,
206
- codeExpiresAt
207
- }) => {
329
+ sendTwoFactorCode: async ({ email, verificationCode }) => {
208
330
  await smsProvider.send(email, verificationCode);
209
331
  }
210
332
  });
211
333
  ```
212
334
 
213
- `codeDelivery.allowCodeInResponseWithoutSender` controls missing callbacks:
335
+ `codeDelivery.allowCodeInResponseWithoutSender` controls a missing callback:
214
336
 
215
- - `true` returns the generated code in the HTTP response. Use only for local
337
+ - `true`: returns the generated code in the HTTP response. Use only for local
216
338
  development or automated tests.
217
- - `false` rejects the request when the required sender callback is absent. Use
218
- this in production.
219
-
220
- Registration codes are stored as Argon2id hashes in `potential_user` and are
221
- removed by a MongoDB TTL index. 2FA code hashes are stored on the user and are
222
- atomically consumed after a successful token issue.
339
+ - `false`: the route fails when a required callback is absent. Use this in
340
+ production.
223
341
 
224
- ## JWT key files and rotation
342
+ ## JWT keys and rotation
225
343
 
226
- The built-in key provider loads JSON files lazily and refreshes its cache after
344
+ The built-in file key provider loads JSON files lazily and caches them for
227
345
  `jwt.keyCacheTtlMs`.
228
346
 
229
- Private-key JSON has one active signing key:
347
+ Private-key JSON contains the active signing key:
230
348
 
231
349
  ```json
232
350
  {
@@ -235,7 +353,7 @@ Private-key JSON has one active signing key:
235
353
  }
236
354
  ```
237
355
 
238
- Public-key JSON keeps each currently valid verification key:
356
+ Public-key JSON contains every currently valid verification key:
239
357
 
240
358
  ```json
241
359
  {
@@ -251,84 +369,99 @@ Public-key JSON keeps each currently valid verification key:
251
369
  Rotation process:
252
370
 
253
371
  1. Add the new public key and `kid` to the public-key JSON.
254
- 2. Switch the active private-key JSON to the matching new key and `kid`.
255
- 3. Keep the previous public key until all tokens signed by it have expired.
256
-
257
- Never commit or publish private keys.
372
+ 2. Switch the private-key JSON to the matching active key and `kid`.
373
+ 3. Keep the old public key until tokens signed by it have expired.
258
374
 
259
- ### Custom key provider
260
-
261
- Use a key provider for KMS, Vault, or a secret manager:
375
+ Never commit private keys. For KMS, Vault, or another secret manager, provide
376
+ `jwt.keyProvider` instead:
262
377
 
263
378
  ```js
264
- const auth = createAuthenticationLibrary({
265
- jwt: {
266
- keyProvider: {
267
- async getActiveSigningKey() {
268
- return {
269
- keyId: "101",
270
- privateKey: await secretManager.getActivePrivateKey()
271
- };
272
- },
273
- async getPublicKey(keyId) {
274
- return secretManager.getPublicKey(keyId);
275
- }
379
+ jwt: {
380
+ keyProvider: {
381
+ async getActiveSigningKey() {
382
+ return {
383
+ keyId: "101",
384
+ privateKey: await secretManager.getActivePrivateKey()
385
+ };
386
+ },
387
+ async getPublicKey(keyId) {
388
+ return secretManager.getPublicKey(keyId);
276
389
  }
277
390
  }
278
- });
391
+ }
279
392
  ```
280
393
 
281
- When `jwt.keyProvider` is supplied, the file paths are not used.
394
+ When `jwt.keyProvider` is present, `jwt.keys` is not used.
282
395
 
283
396
  ## HTTP API
284
397
 
285
- Assume the router is mounted at `/auth`.
286
-
287
- | Method and route | Request body | Description |
288
- | --- | --- | --- |
289
- | `POST /auth/potential-users` | `email` | Generates or refreshes a registration code for an unregistered email. |
290
- | `POST /auth/register` | `email`, `password`, optional `verificationCode` | Creates a user. The code is required when verification is enabled. |
291
- | `POST /auth/token` | `email`, `password`, optional `twoFactorCode` | Issues an access token and refresh-token cookie. |
292
- | `POST /auth/refresh` | Optional `refresh_token` | Returns a new access token and refresh-token cookie; falls back to cookie. |
293
- | `POST /auth/2fa/code` | `email`, `password` | Generates or refreshes a 2FA code when 2FA is enabled. |
294
- | `PATCH /auth/password` | `newPassword`, optional `user_id` or `email` | Changes own password, or another user's if policy allows. |
295
- | `POST /auth/roles` | `roles`, exactly one of `user_id` or `email` | Adds roles. Requires administrator access token. |
296
- | `DELETE /auth/roles` | `role`, exactly one of `user_id` or `email` | Removes one role. Requires administrator access token. |
297
- | `PUT /auth/roles` | `roles`, exactly one of `user_id` or `email` | Replaces all roles. Requires administrator access token. |
298
-
299
- Access tokens include the application payload and access-token metadata.
300
- Refresh tokens contain only the subject and required token metadata, not the
301
- complete application payload.
302
-
303
- ## Data stored in MongoDB
398
+ ### Per-route middleware
304
399
 
305
- The users collection contains documents like:
400
+ Pass ordered arrays of Express middleware in `createAuthRouter` options:
306
401
 
307
- ```json
308
- {
309
- "user_id": "2d6c2f40-0a3f-4b2e-9d8c-8f3b7f6e1a20",
310
- "email": "alice@example.com",
311
- "passwordHash": "<Argon2id hash>",
312
- "nab_role": ["editor"],
313
- "createdAt": "2026-01-01T00:00:00.000Z"
314
- }
402
+ ```js
403
+ const router = auth.createAuthRouter(createTokenPayload, {
404
+ middleware: {
405
+ token: { beforeAuth: [loginLimiter, loginAudit] },
406
+ password: {
407
+ beforeAuth: [passwordIpLimiter],
408
+ afterAuth: [passwordUserLimiter]
409
+ }
410
+ }
411
+ });
412
+ app.use("/auth", router);
315
413
  ```
316
414
 
317
- The library creates unique indexes for `email` and `user_id`, and creates the
318
- pending-registration collection plus its TTL index.
415
+ Supported keys are `potentialUsers`, `register`, `token`, `refresh`,
416
+ `twoFactorCode`, `password`, `addRoles`, `removeRole`, and `replaceRoles`.
417
+ Each omitted array defaults to empty. Middleware runs in array order:
418
+ `beforeAuth`, built-in JWT and role checks, `afterAuth`, then the controller.
419
+ `afterAuth` is only permitted for routes with built-in authentication:
420
+ password and role routes, plus registration when
421
+ `userRegistrationRequiresNabAdmin` is enabled. The authenticated claims are
422
+ available as `req.auth` after those checks. Middleware may end the response or
423
+ call `next(error)` in the usual Express way.
424
+
425
+ The application supplies and configures limiters, including any shared store
426
+ needed across processes. Apply middleware to application-owned routes directly
427
+ with Express. Configure Express proxy trust to match your deployment before
428
+ using client IP addresses for rate limiting.
429
+
430
+ Mount the router at `/auth` to use these routes:
431
+
432
+ | Method and route | Request body | Success | Notes |
433
+ | --- | --- | --- | --- |
434
+ | `POST /auth/potential-users` | `email` | `201` or `202` | Starts/refreshes registration verification. `202` is used when a sender callback is configured. |
435
+ | `POST /auth/register` | `email`, `password`, optional `verificationCode` | `201` | Code required when registration verification is enabled. |
436
+ | `POST /auth/token` | `email`, `password`, optional `twoFactorCode` | `200` | Returns access token and sets refresh-token cookie. |
437
+ | `POST /auth/refresh` | Optional `refresh_token` | `200` | Uses body token first, then the configured cookie. Issues a new refresh token. |
438
+ | `POST /auth/2fa/code` | `email`, `password` | `201` or `202` | Only useful when 2FA is enabled. |
439
+ | `PATCH /auth/password` | `newPassword`, optional `user_id` or `email` | `204` | Requires JWT; authorization settings can require admin access. |
440
+ | `POST /auth/roles` | `roles`, exactly one of `user_id` or `email` | `200` | Requires administrator access token. |
441
+ | `DELETE /auth/roles` | `role`, exactly one of `user_id` or `email` | `200` | Requires administrator access token. |
442
+ | `PUT /auth/roles` | `roles`, exactly one of `user_id` or `email` | `200` | Requires administrator access token. |
443
+
444
+ Access tokens include the application payload. Refresh tokens contain only the
445
+ subject and token metadata. The library does not persist refresh tokens or
446
+ revoke previously issued refresh tokens; the host application should add a
447
+ token-revocation strategy if that is required.
319
448
 
320
449
  ## Verifier-only service
321
450
 
322
- A business service that only verifies access tokens does not need MongoDB or a
451
+ A service that only validates access tokens needs no database adapter and no
323
452
  private key:
324
453
 
325
454
  ```js
455
+ import express from "express";
456
+ import { createAuthenticationLibrary } from "@nabhiraj/nab-auth";
457
+
458
+ const app = express();
326
459
  const auth = createAuthenticationLibrary({
327
460
  jwt: {
328
461
  issuer: "my-service",
329
462
  audience: "my-client",
330
463
  keys: {
331
- publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
464
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
332
465
  }
333
466
  }
334
467
  });
@@ -340,16 +473,39 @@ app.get("/api/business-data", auth.requireJwt, (req, res) => {
340
473
  app.use(auth.jwtErrorHandler);
341
474
  ```
342
475
 
476
+ Do not call `connectToDatabase()` or `createAuthRouter()` in this mode.
477
+
478
+ ## Testing
479
+
480
+ Run unit and adapter-contract tests:
481
+
482
+ ```bash
483
+ npm test
484
+ ```
485
+
486
+ Run the opt-in live MongoDB end-to-end suite only with dedicated test
487
+ credentials and an isolated test database:
488
+
489
+ ```bash
490
+ npm run test:e2e:mongo
491
+ ```
492
+
493
+ See [E2E_TEST_PLAN.md](E2E_TEST_PLAN.md) for required endpoint and
494
+ configuration coverage. Never point live tests at the host application’s
495
+ database.
496
+
343
497
  ## Production checklist
344
498
 
345
- - Use HTTPS and `refreshCookie.secure: true` in production.
346
- - Set `codeDelivery.allowCodeInResponseWithoutSender: false` in production.
347
- - Provide real email/SMS delivery callbacks for codes.
348
- - Keep private keys and MongoDB credentials outside source control.
349
- - Add application-level rate limits to login, registration, code-generation,
350
- and password-change routes.
351
- - Configure CORS and cookie `sameSite` settings for your frontend topology.
352
- - Retain old public keys until tokens signed by them expire.
499
+ - Keep database credentials and private keys outside source control.
500
+ - Use HTTPS and `refreshCookie.secure: true`.
501
+ - Set `codeDelivery.allowCodeInResponseWithoutSender: false`.
502
+ - Provide real code-delivery callbacks for enabled verification features.
503
+ - Add application-level rate limits to login, registration, code generation,
504
+ refresh, and password-change routes.
505
+ - Configure CORS and `sameSite` for the frontend topology.
506
+ - Retain old public keys until every token signed with them expires.
507
+ - Define refresh-token revocation if logout, breach response, or session
508
+ invalidation requires it.
353
509
 
354
510
  ## Support and contributing
355
511
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nabhiraj/nab-auth",
3
- "version": "0.1.1",
4
- "description": "Configurable Express, MongoDB, JWT, registration verification, and 2FA authentication library",
3
+ "version": "1.1.0",
4
+ "description": "Configurable Express authentication library with pluggable database adapters, JWT, registration verification, and 2FA",
5
5
  "type": "module",
6
6
  "main": "./src/authenticationLogic/index.js",
7
7
  "exports": {
@@ -0,0 +1,28 @@
1
+ export const authStoreMethods = [
2
+ "connect",
3
+ "close",
4
+ "findUserByEmail",
5
+ "findUserByUserId",
6
+ "createUser",
7
+ "upsertPotentialUser",
8
+ "findPotentialUserByEmail",
9
+ "deletePotentialUserByEmail",
10
+ "updateUserPassword",
11
+ "updateUserTwoFactorCode",
12
+ "consumeUserTwoFactorCode",
13
+ "addUserRoles",
14
+ "removeUserRole",
15
+ "replaceUserRoles"
16
+ ];
17
+
18
+ export function assertAuthStore(store) {
19
+ if (!store || typeof store !== "object") {
20
+ throw new TypeError("database.adapter must be an auth store object");
21
+ }
22
+
23
+ for (const method of authStoreMethods) {
24
+ if (typeof store[method] !== "function") {
25
+ throw new TypeError(`database.adapter must provide a ${method} function`);
26
+ }
27
+ }
28
+ }
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import argon2 from "argon2";
3
2
  import { MongoClient } from "mongodb";
3
+ import { normalizeEmail } from "../utils/normalizeEmail.js";
4
4
 
5
- export function createUserRepository({ mongo, defaultAdmin, passwordHash }) {
5
+ export function createMongoAuthStore(mongo) {
6
6
  let client;
7
7
  let usersCollection;
8
8
  let potentialUsersCollection;
@@ -29,57 +29,10 @@ export function createUserRepository({ mongo, defaultAdmin, passwordHash }) {
29
29
  return usersCollection.findOne({ user_id: userId });
30
30
  }
31
31
 
32
- async function insertUser(email, passwordHashValue) {
33
- requireUsersCollection();
34
- const userId = randomUUID();
35
-
36
- await usersCollection.insertOne({
37
- user_id: userId,
38
- email: normalizeEmail(email),
39
- passwordHash: passwordHashValue,
40
- nab_role: [],
41
- createdAt: new Date()
42
- });
43
-
44
- return userId;
45
- }
46
-
47
- async function ensureDefaultAdminUser() {
48
- if (!defaultAdmin.enabled) {
49
- return;
50
- }
51
-
52
- const existingAdmin = await findUserByEmail(defaultAdmin.email);
53
-
54
- if (existingAdmin) {
55
- return;
56
- }
57
-
58
- const passwordHashValue = await argon2.hash(defaultAdmin.password, {
59
- type: argon2.argon2id,
60
- ...passwordHash
61
- });
62
-
63
- try {
64
- const userId = randomUUID();
65
- await usersCollection.insertOne({
66
- user_id: userId,
67
- email: normalizeEmail(defaultAdmin.email),
68
- passwordHash: passwordHashValue,
69
- nab_role: defaultAdmin.roles,
70
- createdAt: new Date()
71
- });
72
- } catch (error) {
73
- if (error.code !== 11000) {
74
- throw error;
75
- }
76
- }
77
- }
78
-
79
32
  return {
80
- async connectToDatabase() {
33
+ async connect() {
81
34
  if (usersCollection) {
82
- return usersCollection;
35
+ return;
83
36
  }
84
37
 
85
38
  if (typeof mongo.uri !== "string" || !mongo.uri) {
@@ -107,12 +60,9 @@ export function createUserRepository({ mongo, defaultAdmin, passwordHash }) {
107
60
  { expiresAt: 1 },
108
61
  { expireAfterSeconds: 0 }
109
62
  );
110
- await ensureDefaultAdminUser();
111
-
112
- return usersCollection;
113
63
  },
114
64
 
115
- async closeDatabase() {
65
+ async close() {
116
66
  usersCollection = undefined;
117
67
  potentialUsersCollection = undefined;
118
68
 
@@ -124,7 +74,25 @@ export function createUserRepository({ mongo, defaultAdmin, passwordHash }) {
124
74
 
125
75
  findUserByEmail,
126
76
  findUserByUserId,
127
- insertUser,
77
+
78
+ async createUser(user) {
79
+ requireUsersCollection();
80
+
81
+ try {
82
+ await usersCollection.insertOne({
83
+ ...user,
84
+ email: normalizeEmail(user.email),
85
+ nab_role: user.nab_role || [],
86
+ createdAt: user.createdAt || new Date()
87
+ });
88
+ return true;
89
+ } catch (error) {
90
+ if (error.code === 11000) {
91
+ return false;
92
+ }
93
+ throw error;
94
+ }
95
+ },
128
96
 
129
97
  async upsertPotentialUser(
130
98
  email,
@@ -275,7 +243,3 @@ async function updateUserRoles(usersCollection, userId, update) {
275
243
 
276
244
  return usersCollection.findOne({ user_id: userId });
277
245
  }
278
-
279
- export function normalizeEmail(email) {
280
- return email.trim().toLowerCase();
281
- }
@@ -15,6 +15,18 @@ export function createAuthConfig(overrides = {}) {
15
15
  validateCodeConfig(config.twoFactor, "twoFactor", "required");
16
16
  validateString(config.authorization.adminRole, "authorization.adminRole");
17
17
 
18
+ if (config.registration.validatePassword !== undefined &&
19
+ typeof config.registration.validatePassword !== "function") {
20
+ throw new TypeError("registration.validatePassword must be a function");
21
+ }
22
+
23
+ if (
24
+ config.database.adapter !== undefined &&
25
+ typeof config.database.adapter !== "function"
26
+ ) {
27
+ throw new TypeError("database.adapter must be an adapter factory function");
28
+ }
29
+
18
30
  if (typeof config.codeDelivery.allowCodeInResponseWithoutSender !== "boolean") {
19
31
  throw new TypeError(
20
32
  "codeDelivery.allowCodeInResponseWithoutSender must be a boolean"
@@ -1,9 +1,7 @@
1
1
  export const defaultAuthConfig = {
2
- mongo: {
3
- uri: undefined,
4
- databaseName: "jwt_auth_demo",
5
- usersCollectionName: "users",
6
- potentialUsersCollectionName: "potential_user"
2
+ database: {
3
+ adapter: undefined,
4
+ adapterConfig: undefined
7
5
  },
8
6
  jwt: {
9
7
  issuer: "jwt-express-demo",
@@ -26,6 +24,7 @@ export const defaultAuthConfig = {
26
24
  },
27
25
  registration: {
28
26
  verificationRequired: false,
27
+ validatePassword: undefined,
29
28
  codeDigits: 6,
30
29
  codeTtlMs: 10 * 60 * 1000
31
30
  },
@@ -1,6 +1,7 @@
1
1
  import argon2 from "argon2";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { parseCookie } from "cookie";
3
- import { normalizeEmail } from "../repositories/userRepository.js";
4
+ import { normalizeEmail } from "../utils/normalizeEmail.js";
4
5
  import {
5
6
  generateNumericVerificationCode,
6
7
  hashVerificationCode,
@@ -142,6 +143,16 @@ export function createAuthController({
142
143
  return res.status(400).json({ message: "email and password are required" });
143
144
  }
144
145
 
146
+ if (config.registration.validatePassword) {
147
+ const valid = await config.registration.validatePassword(password);
148
+ if (typeof valid !== "boolean") {
149
+ throw new TypeError("registration.validatePassword must return a boolean");
150
+ }
151
+ if (!valid) {
152
+ return res.status(400).json({ message: "Password does not meet requirements" });
153
+ }
154
+ }
155
+
145
156
  const normalizedEmail = normalizeEmail(email);
146
157
  if (await repository.findUserByEmail(normalizedEmail)) {
147
158
  return res.status(409).json({ message: "User already exists" });
@@ -170,23 +181,24 @@ export function createAuthController({
170
181
  }
171
182
  }
172
183
 
173
- try {
174
- const userId = await repository.insertUser(
175
- normalizedEmail,
176
- await hashPassword(password, config.passwordHash)
177
- );
184
+ const userId = randomUUID();
185
+ const userCreated = await repository.createUser({
186
+ user_id: userId,
187
+ email: normalizedEmail,
188
+ passwordHash: await hashPassword(password, config.passwordHash),
189
+ nab_role: [],
190
+ createdAt: new Date()
191
+ });
178
192
 
179
- if (config.registration.verificationRequired) {
180
- await repository.deletePotentialUserByEmail(normalizedEmail);
181
- }
193
+ if (!userCreated) {
194
+ return res.status(409).json({ message: "User already exists" });
195
+ }
182
196
 
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;
197
+ if (config.registration.verificationRequired) {
198
+ await repository.deletePotentialUserByEmail(normalizedEmail);
189
199
  }
200
+
201
+ return res.status(201).json({ user_id: userId, email: normalizedEmail });
190
202
  } catch (error) {
191
203
  next(error);
192
204
  }
@@ -1,19 +1,33 @@
1
1
  import { createAuthConfig } from "./config/createAuthConfig.js";
2
2
  import { createAuthController } from "./controllers/authController.js";
3
3
  import { createJwtMiddleware } from "./middleware/jwtMiddleware.js";
4
- import { createUserRepository } from "./repositories/userRepository.js";
4
+ import { assertAuthStore } from "./adapters/authStore.js";
5
5
  import { createAuthRouter } from "./routes/authRoutes.js";
6
+ import { ensureDefaultAdminUser } from "./services/defaultAdminService.js";
6
7
  import { createJwtKeyProvider } from "./services/jwtKeyProvider.js";
7
8
  import { createTokenService } from "./services/tokenService.js";
8
9
 
9
10
  export function createAuthenticationLibrary(configOverrides = {}) {
10
11
  const config = createAuthConfig(configOverrides);
11
12
  const keyProvider = createJwtKeyProvider(config.jwt);
12
- const repository = createUserRepository({
13
- mongo: config.mongo,
14
- defaultAdmin: config.defaultAdmin,
15
- passwordHash: config.passwordHash
16
- });
13
+ let repository;
14
+
15
+ function getRepository() {
16
+ if (repository) {
17
+ return repository;
18
+ }
19
+
20
+ if (typeof config.database.adapter !== "function") {
21
+ throw new Error(
22
+ "database.adapter must be configured before connecting to a database or creating an authentication router"
23
+ );
24
+ }
25
+
26
+ repository = config.database.adapter(config.database.adapterConfig);
27
+ assertAuthStore(repository);
28
+ return repository;
29
+ }
30
+
17
31
  const tokenService = createTokenService({ jwt: config.jwt, keyProvider });
18
32
  const middleware = createJwtMiddleware({
19
33
  jwt: config.jwt,
@@ -23,21 +37,33 @@ export function createAuthenticationLibrary(configOverrides = {}) {
23
37
 
24
38
  return {
25
39
  config,
26
- connectToDatabase: repository.connectToDatabase,
27
- closeDatabase: repository.closeDatabase,
40
+ async connectToDatabase() {
41
+ const store = getRepository();
42
+ await store.connect();
43
+ await ensureDefaultAdminUser({
44
+ store,
45
+ defaultAdmin: config.defaultAdmin,
46
+ passwordHash: config.passwordHash
47
+ });
48
+ },
49
+ async closeDatabase() {
50
+ if (repository) {
51
+ await repository.close();
52
+ }
53
+ },
28
54
  requireJwt: middleware.requireJwt,
29
55
  requireNabAdmin: middleware.requireNabAdmin,
30
56
  jwtErrorHandler: middleware.jwtErrorHandler,
31
57
  createAuthRouter(payloadFactory, options = {}) {
32
58
  const controller = createAuthController({
33
59
  config,
34
- repository,
60
+ repository: getRepository(),
35
61
  tokenService,
36
62
  payloadFactory,
37
63
  options
38
64
  });
39
65
 
40
- return createAuthRouter({ controller, middleware, config });
66
+ return createAuthRouter({ controller, middleware, config, routeMiddleware: options.middleware });
41
67
  }
42
68
  };
43
69
  }
@@ -1,3 +1,5 @@
1
1
  export { createAuthenticationLibrary } from "./createAuthenticationLibrary.js";
2
2
  export { defaultAuthConfig } from "./config/defaultAuthConfig.js";
3
3
  export { createFileJwtKeyProvider } from "./services/jwtKeyProvider.js";
4
+ export { createMongoAuthStore } from "./adapters/mongoAuthStore.js";
5
+ export { authStoreMethods, assertAuthStore } from "./adapters/authStore.js";
@@ -1,7 +1,8 @@
1
1
  import { Router } from "express";
2
2
 
3
- export function createAuthRouter({ controller, middleware, config }) {
3
+ export function createAuthRouter({ controller, middleware, config, routeMiddleware = {} }) {
4
4
  const authRouter = Router();
5
+ console.log('route middleware is ',routeMiddleware);
5
6
 
6
7
  const registrationMiddleware = config.authorization.userRegistrationRequiresNabAdmin
7
8
  ? [middleware.requireJwt, middleware.requireNabAdmin]
@@ -14,27 +15,49 @@ export function createAuthRouter({ controller, middleware, config }) {
14
15
  middleware.requireNabAdmin
15
16
  ];
16
17
 
17
- authRouter.post("/roles", ...roleManagementMiddleware, controller.addRoles);
18
- authRouter.delete(
19
- "/roles",
20
- ...roleManagementMiddleware,
21
- controller.removeRole
22
- );
23
- authRouter.put(
24
- "/roles",
25
- ...roleManagementMiddleware,
26
- controller.replaceRoles
27
- );
28
- authRouter.patch(
29
- "/password",
30
- ...passwordChangeMiddleware,
31
- controller.changePassword
32
- );
33
- authRouter.post("/potential-users", controller.createPotentialUser);
34
- authRouter.post("/2fa/code", controller.generateTwoFactorCode);
35
- authRouter.post("/register", ...registrationMiddleware, controller.registerUser);
36
- authRouter.post("/token", controller.issueToken);
37
- authRouter.post("/refresh", controller.refreshToken);
18
+ const routes = {
19
+ addRoles: ["post", "/roles", roleManagementMiddleware, controller.addRoles],
20
+ removeRole: ["delete", "/roles", roleManagementMiddleware, controller.removeRole],
21
+ replaceRoles: ["put", "/roles", roleManagementMiddleware, controller.replaceRoles],
22
+ password: ["patch", "/password", passwordChangeMiddleware, controller.changePassword],
23
+ potentialUsers: ["post", "/potential-users", [], controller.createPotentialUser],
24
+ twoFactorCode: ["post", "/2fa/code", [], controller.generateTwoFactorCode],
25
+ register: ["post", "/register", registrationMiddleware, controller.registerUser],
26
+ token: ["post", "/token", [], controller.issueToken],
27
+ refresh: ["post", "/refresh", [], controller.refreshToken]
28
+ };
29
+
30
+ if (!routeMiddleware || typeof routeMiddleware !== "object" || Array.isArray(routeMiddleware)) {
31
+ throw new TypeError("options.middleware must be an object");
32
+ }
33
+ for (const name of Object.keys(routeMiddleware)) {
34
+ if (!Object.hasOwn(routes, name)) {
35
+ throw new TypeError(`Unknown middleware route: ${name}`);
36
+ }
37
+ }
38
+
39
+ for (const [name, [method, path, auth, handler]] of Object.entries(routes)) {
40
+ const settings = routeMiddleware[name] ?? {};
41
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
42
+ throw new TypeError(`options.middleware.${name} must be an object`);
43
+ }
44
+ for (const key of Object.keys(settings)) {
45
+ if (key !== "beforeAuth" && key !== "afterAuth") {
46
+ throw new TypeError(`Unknown middleware position: ${name}.${key}`);
47
+ }
48
+ }
49
+ const before = settings.beforeAuth ?? [];
50
+ const after = settings.afterAuth ?? [];
51
+ for (const [position, value] of [["beforeAuth", before], ["afterAuth", after]]) {
52
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "function")) {
53
+ throw new TypeError(`options.middleware.${name}.${position} must be an array of functions`);
54
+ }
55
+ }
56
+ if (!auth.length && Object.hasOwn(settings, "afterAuth")) {
57
+ throw new TypeError(`options.middleware.${name}.afterAuth requires built-in authentication`);
58
+ }
59
+ authRouter[method](path, ...before, ...auth, ...after, handler);
60
+ }
38
61
 
39
62
  return authRouter;
40
63
  }
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import argon2 from "argon2";
3
+ import { normalizeEmail } from "../utils/normalizeEmail.js";
4
+
5
+ export async function ensureDefaultAdminUser({ store, defaultAdmin, passwordHash }) {
6
+ if (!defaultAdmin.enabled) {
7
+ return;
8
+ }
9
+
10
+ const email = normalizeEmail(defaultAdmin.email);
11
+ if (await store.findUserByEmail(email)) {
12
+ return;
13
+ }
14
+
15
+ const passwordHashValue = await argon2.hash(defaultAdmin.password, {
16
+ type: argon2.argon2id,
17
+ ...passwordHash
18
+ });
19
+
20
+ await store.createUser({
21
+ user_id: randomUUID(),
22
+ email,
23
+ passwordHash: passwordHashValue,
24
+ nab_role: defaultAdmin.roles,
25
+ createdAt: new Date()
26
+ });
27
+ }
@@ -0,0 +1,3 @@
1
+ export function normalizeEmail(email) {
2
+ return email.trim().toLowerCase();
3
+ }