@nabhiraj/nab-auth 0.1.1 → 1.0.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
@@ -2,32 +2,36 @@
2
2
 
3
3
  [![license](https://img.shields.io/npm/l/@nabhiraj/nab-auth.svg)](LICENSE)
4
4
 
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.
5
+ `@nabhiraj/nab-auth` is a configurable Express authentication library. It
6
+ provides Argon2id password hashing, RS256 access and refresh tokens, roles,
7
+ registration verification, and optional two-factor authentication. Storage is
8
+ provided by an explicit adapter: MongoDB/Atlas support is bundled, while other
9
+ databases can be added by implementing the auth-store contract.
8
10
 
9
- The library does not load environment variables or send email/SMS itself. The
10
- host application owns configuration and code delivery.
11
+ The host application owns environment loading, database credentials, JWT key
12
+ locations, and email/SMS delivery. The library does not load `.env` files or
13
+ send messages itself.
11
14
 
12
15
  ## Features
13
16
 
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
17
+ - Email/password registration and login with Argon2id hashes
18
+ - RS256 access tokens and refresh-token cookies
19
+ - JWT `kid` support for public-key rotation
20
+ - Role claims and administrator-protected role endpoints
18
21
  - 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
22
+ - Explicit database adapters; MongoDB/Atlas adapter included
23
+ - Verifier-only mode for services that only validate access tokens
21
24
 
22
25
  ## Requirements
23
26
 
24
- - Node.js 20 or later
25
- - Express 5
26
- - MongoDB or MongoDB Atlas
27
- - An RSA key pair for RS256 JWT signing
27
+ - Node.js 20 or newer
28
+ - Express 5 (peer dependency)
29
+ - An RSA key pair for RS256 signing and verification
30
+ - A database adapter for services that register users, issue tokens, or use
31
+ the authentication router
28
32
 
29
- `argon2` is a native dependency. Most supported systems use a prebuilt binary;
30
- unusual platforms may need standard Node.js native build tools.
33
+ `argon2` is a native dependency. Most platforms use a prebuilt binary; unusual
34
+ platforms may require standard Node.js native build tools.
31
35
 
32
36
  ## Installation
33
37
 
@@ -35,26 +39,40 @@ unusual platforms may need standard Node.js native build tools.
35
39
  npm install @nabhiraj/nab-auth express
36
40
  ```
37
41
 
38
- ## Quick start
42
+ ## Quick start: MongoDB
43
+
44
+ The application loads its own environment configuration and passes the values
45
+ to the adapter. The library does not require a particular `.env` package or
46
+ variable naming convention.
39
47
 
40
48
  ```js
49
+ import "dotenv/config";
41
50
  import express from "express";
42
- import { createAuthenticationLibrary } from "@nabhiraj/nab-auth";
51
+ import {
52
+ createAuthenticationLibrary,
53
+ createMongoAuthStore
54
+ } from "@nabhiraj/nab-auth";
43
55
 
44
56
  const app = express();
45
57
  app.use(express.json());
46
58
 
47
59
  const auth = createAuthenticationLibrary({
48
- mongo: {
49
- uri: process.env.MY_AUTH_MONGO_URI,
50
- databaseName: "my_auth_database"
60
+ database: {
61
+ adapter: createMongoAuthStore,
62
+ adapterConfig: {
63
+ uri: process.env.MONGODB_URI,
64
+ databaseName: process.env.MONGODB_DB_NAME,
65
+ usersCollectionName: process.env.MONGODB_USERS_COLLECTION || "users",
66
+ potentialUsersCollectionName:
67
+ process.env.MONGODB_POTENTIAL_USERS_COLLECTION || "potential_users"
68
+ }
51
69
  },
52
70
  jwt: {
53
- issuer: "my-service",
54
- audience: "my-client",
71
+ issuer: process.env.JWT_ISSUER,
72
+ audience: process.env.JWT_AUDIENCE,
55
73
  keys: {
56
- privateKeyPath: process.env.MY_JWT_PRIVATE_KEY_PATH,
57
- publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
74
+ privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH,
75
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
58
76
  }
59
77
  },
60
78
  defaultAdmin: { enabled: false }
@@ -69,10 +87,10 @@ app.use(
69
87
  "/auth",
70
88
  auth.createAuthRouter(createTokenPayload, {
71
89
  sendRegistrationCode: async ({ email, verificationCode }) => {
72
- await sendEmail(email, `Your verification code is ${verificationCode}`);
90
+ await emailProvider.send(email, `Your verification code is ${verificationCode}`);
73
91
  },
74
92
  sendTwoFactorCode: async ({ email, verificationCode }) => {
75
- await sendEmail(email, `Your 2FA code is ${verificationCode}`);
93
+ await emailProvider.send(email, `Your 2FA code is ${verificationCode}`);
76
94
  }
77
95
  })
78
96
  );
@@ -86,49 +104,139 @@ await auth.connectToDatabase();
86
104
  app.listen(3000);
87
105
  ```
88
106
 
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.
107
+ The order matters: call `connectToDatabase()` before receiving requests, and
108
+ mount `jwtErrorHandler` after routes that use `requireJwt`.
92
109
 
93
110
  ## Core API
94
111
 
95
112
  ```js
96
113
  import {
114
+ assertAuthStore,
115
+ authStoreMethods,
97
116
  createAuthenticationLibrary,
98
117
  createFileJwtKeyProvider,
118
+ createMongoAuthStore,
99
119
  defaultAuthConfig
100
120
  } from "@nabhiraj/nab-auth";
101
121
  ```
102
122
 
103
- `createAuthenticationLibrary(config)` creates isolated state: its MongoDB
104
- client, JWT-key cache, middleware, and router do not share state with another
123
+ `createAuthenticationLibrary(config)` creates isolated state. Its adapter,
124
+ JWT-key cache, middleware, and router are not shared with another library
105
125
  instance.
106
126
 
107
127
  | Member | Purpose |
108
128
  | --- | --- |
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. |
129
+ | `connectToDatabase()` | Creates the configured adapter, connects it, and creates the default admin when enabled. |
130
+ | `closeDatabase()` | Closes the adapter if it was created. |
131
+ | `createAuthRouter(payloadFactory, options)` | Creates the storage-backed Express authentication router. |
132
+ | `requireJwt` | Validates an access token and sets claims on `req.auth`. |
133
+ | `requireNabAdmin` | Requires the configured administrator role after `requireJwt`. |
134
+ | `jwtErrorHandler` | Converts `express-jwt` authentication failures to a `401` response. |
135
+ | `config` | The merged configuration for this library instance. |
136
+
137
+ `payloadFactory(user)` must return an object. Its properties are included in
138
+ new access tokens; the library adds `sub`, JWT metadata, and
139
+ `tokenType: "access"`.
140
+
141
+ ## Database adapters
142
+
143
+ There is no default database. A storage-backed service must configure
144
+ `database.adapter` as a factory function and pass adapter-specific values in
145
+ `database.adapterConfig`.
146
+
147
+ ```js
148
+ const auth = createAuthenticationLibrary({
149
+ database: {
150
+ adapter: createPostgresAuthStore,
151
+ adapterConfig: {
152
+ connectionString: process.env.DATABASE_URL,
153
+ usersTableName: "users",
154
+ pendingUsersTableName: "pending_users"
155
+ }
156
+ }
157
+ });
158
+ ```
159
+
160
+ Import the adapter in the application and pass the function itself. Do not pass
161
+ a source-file path or make the library dynamically load a module.
162
+
163
+ ### MongoDB adapter
164
+
165
+ The package exports `createMongoAuthStore`. Its adapter configuration is:
166
+
167
+ | Field | Required | Description |
168
+ | --- | --- | --- |
169
+ | `uri` | Yes | MongoDB or MongoDB Atlas connection URI. |
170
+ | `databaseName` | Yes | Database used exclusively for auth data. |
171
+ | `usersCollectionName` | Yes | Collection for permanent user records. |
172
+ | `potentialUsersCollectionName` | Yes | Collection for pending registration records. |
173
+
174
+ On `connect()`, the Mongo adapter creates missing collections and indexes:
175
+
176
+ - Unique indexes for `users.email` and `users.user_id`
177
+ - A unique index for pending-user `email`
178
+ - A TTL index on pending-user `expiresAt`
179
+
180
+ It also migrates existing Mongo user records that lack `user_id` or `nab_role`.
181
+
182
+ ### Custom adapter contract
183
+
184
+ An adapter factory receives `adapterConfig` and returns an object that
185
+ implements every method below. `assertAuthStore(store)` validates the shape,
186
+ and `authStoreMethods` exports the method names.
187
+
188
+ | Method | Required behavior |
189
+ | --- | --- |
190
+ | `connect()` | Open the database connection and prepare storage. |
191
+ | `close()` | Release the database connection/resources. |
192
+ | `findUserByEmail(email)` | Return the user record or `null`. |
193
+ | `findUserByUserId(userId)` | Return the user record or `null`. |
194
+ | `createUser(user)` | Persist the user and return `true`; return `false` when its email already exists. |
195
+ | `upsertPotentialUser(email, hash, generatedAt, expiresAt)` | Create/update the pending registration; return `false` when the email is already registered. |
196
+ | `findPotentialUserByEmail(email)` | Return the pending record or `null`. |
197
+ | `deletePotentialUserByEmail(email)` | Delete the pending registration if present. |
198
+ | `updateUserPassword(userId, hash)` | Update the password hash; return whether a user matched. |
199
+ | `updateUserTwoFactorCode(userId, hash, generatedAt, expiresAt)` | Store a new 2FA code hash; return whether a user matched. |
200
+ | `consumeUserTwoFactorCode(userId, hash)` | Atomically consume exactly that unused 2FA hash; return whether it was consumed. |
201
+ | `addUserRoles(userId, roles)` | Add unique roles and return the updated user or `null`. |
202
+ | `removeUserRole(userId, role)` | Remove a role and return the updated user or `null`. |
203
+ | `replaceUserRoles(userId, roles)` | Replace all roles and return the updated user or `null`. |
204
+
205
+ User records returned by an adapter must provide at least:
116
206
 
117
- `payloadFactory(user)` creates application-specific access-token claims. The
118
- library adds standard JWT claims, `sub`, and `tokenType: "access"`.
207
+ ```js
208
+ {
209
+ user_id: "uuid",
210
+ email: "person@example.com",
211
+ passwordHash: "argon2 hash",
212
+ nab_role: ["role"],
213
+ createdAt: new Date()
214
+ }
215
+ ```
216
+
217
+ Pending records must provide `verificationCodeHash` and `codeExpiresAt` as a
218
+ JavaScript `Date`. User records with a pending 2FA challenge use
219
+ `twoFactorCodeHash` and `twoFactorCodeExpiresAt`.
220
+
221
+ The core normalizes email before it invokes the store. Custom adapters should
222
+ preserve that normalized email value and enforce unique email storage.
119
223
 
120
224
  ## Configuration
121
225
 
122
- Every setting is optional unless a feature needs it. Omitted values use
123
- `defaultAuthConfig`.
226
+ All settings use `defaultAuthConfig` when omitted. `database.adapter` is
227
+ required before calling `connectToDatabase()` or `createAuthRouter()`. A
228
+ verifier-only service can omit it.
124
229
 
125
230
  ```js
126
231
  const auth = createAuthenticationLibrary({
127
- mongo: {
128
- uri: process.env.MY_AUTH_MONGO_URI,
129
- databaseName: "auth_db",
130
- usersCollectionName: "users",
131
- potentialUsersCollectionName: "potential_user"
232
+ database: {
233
+ adapter: createMongoAuthStore,
234
+ adapterConfig: {
235
+ uri: process.env.MONGODB_URI,
236
+ databaseName: process.env.MONGODB_DB_NAME,
237
+ usersCollectionName: "users",
238
+ potentialUsersCollectionName: "potential_users"
239
+ }
132
240
  },
133
241
  jwt: {
134
242
  issuer: "my-service",
@@ -138,8 +246,8 @@ const auth = createAuthenticationLibrary({
138
246
  refreshTokenTtl: "7d",
139
247
  keyCacheTtlMs: 15 * 60 * 1000,
140
248
  keys: {
141
- privateKeyPath: process.env.MY_PRIVATE_KEY_PATH,
142
- publicKeyPath: process.env.MY_PUBLIC_KEY_PATH
249
+ privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH,
250
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
143
251
  }
144
252
  },
145
253
  authorization: {
@@ -158,11 +266,6 @@ const auth = createAuthenticationLibrary({
158
266
  codeDigits: 6,
159
267
  codeTtlMs: 10 * 60 * 1000
160
268
  },
161
- passwordHash: {
162
- memoryCost: 19456,
163
- timeCost: 2,
164
- parallelism: 1
165
- },
166
269
  refreshCookie: {
167
270
  name: "refresh_token",
168
271
  httpOnly: true,
@@ -176,57 +279,55 @@ const auth = createAuthenticationLibrary({
176
279
  },
177
280
  defaultAdmin: {
178
281
  enabled: false,
179
- email: "admin@admin.com",
180
- password: "change-this",
282
+ email: "admin@example.com",
283
+ password: "use-a-strong-secret",
181
284
  roles: ["nab_admin"]
182
285
  }
183
286
  });
184
287
  ```
185
288
 
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.
289
+ `refreshCookie.secure` defaults to `false` for local HTTP development; set it
290
+ to `true` for HTTPS production deployments. `defaultAdmin.enabled` defaults to
291
+ `false`; do not enable it with a known or weak password.
292
+
293
+ ### Registration and authorization policy
294
+
295
+ | Setting | Effect |
296
+ | --- | --- |
297
+ | `registration.verificationRequired` | Requires a valid registration code on `POST /register`. |
298
+ | `twoFactor.required` | Requires a valid 2FA code before issuing tokens. |
299
+ | `authorization.userRegistrationRequiresNabAdmin` | Requires a JWT-bearing administrator to register users. |
300
+ | `authorization.passwordChangeRequiresNabAdmin` | Requires a JWT-bearing administrator for all password changes. |
301
+ | `authorization.nabAdminCanChangeOtherUsersPasswords` | Lets an administrator change another user’s password when admin-only password changes are disabled. |
189
302
 
190
303
  ### Code delivery callbacks
191
304
 
305
+ Supply callbacks only if the associated feature is enabled:
306
+
192
307
  ```js
193
308
  auth.createAuthRouter(createTokenPayload, {
194
- sendRegistrationCode: async ({
195
- email,
196
- verificationCode,
197
- codeGeneratedAt,
198
- codeExpiresAt
199
- }) => {
309
+ sendRegistrationCode: async ({ email, verificationCode }) => {
200
310
  await emailProvider.send(email, verificationCode);
201
311
  },
202
- sendTwoFactorCode: async ({
203
- email,
204
- verificationCode,
205
- codeGeneratedAt,
206
- codeExpiresAt
207
- }) => {
312
+ sendTwoFactorCode: async ({ email, verificationCode }) => {
208
313
  await smsProvider.send(email, verificationCode);
209
314
  }
210
315
  });
211
316
  ```
212
317
 
213
- `codeDelivery.allowCodeInResponseWithoutSender` controls missing callbacks:
318
+ `codeDelivery.allowCodeInResponseWithoutSender` controls a missing callback:
214
319
 
215
- - `true` returns the generated code in the HTTP response. Use only for local
320
+ - `true`: returns the generated code in the HTTP response. Use only for local
216
321
  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.
322
+ - `false`: the route fails when a required callback is absent. Use this in
323
+ production.
223
324
 
224
- ## JWT key files and rotation
325
+ ## JWT keys and rotation
225
326
 
226
- The built-in key provider loads JSON files lazily and refreshes its cache after
327
+ The built-in file key provider loads JSON files lazily and caches them for
227
328
  `jwt.keyCacheTtlMs`.
228
329
 
229
- Private-key JSON has one active signing key:
330
+ Private-key JSON contains the active signing key:
230
331
 
231
332
  ```json
232
333
  {
@@ -235,7 +336,7 @@ Private-key JSON has one active signing key:
235
336
  }
236
337
  ```
237
338
 
238
- Public-key JSON keeps each currently valid verification key:
339
+ Public-key JSON contains every currently valid verification key:
239
340
 
240
341
  ```json
241
342
  {
@@ -251,84 +352,67 @@ Public-key JSON keeps each currently valid verification key:
251
352
  Rotation process:
252
353
 
253
354
  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.
355
+ 2. Switch the private-key JSON to the matching active key and `kid`.
356
+ 3. Keep the old public key until tokens signed by it have expired.
256
357
 
257
- Never commit or publish private keys.
258
-
259
- ### Custom key provider
260
-
261
- Use a key provider for KMS, Vault, or a secret manager:
358
+ Never commit private keys. For KMS, Vault, or another secret manager, provide
359
+ `jwt.keyProvider` instead:
262
360
 
263
361
  ```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
- }
362
+ jwt: {
363
+ keyProvider: {
364
+ async getActiveSigningKey() {
365
+ return {
366
+ keyId: "101",
367
+ privateKey: await secretManager.getActivePrivateKey()
368
+ };
369
+ },
370
+ async getPublicKey(keyId) {
371
+ return secretManager.getPublicKey(keyId);
276
372
  }
277
373
  }
278
- });
374
+ }
279
375
  ```
280
376
 
281
- When `jwt.keyProvider` is supplied, the file paths are not used.
377
+ When `jwt.keyProvider` is present, `jwt.keys` is not used.
282
378
 
283
379
  ## HTTP API
284
380
 
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
304
-
305
- The users collection contains documents like:
306
-
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
- }
315
- ```
316
-
317
- The library creates unique indexes for `email` and `user_id`, and creates the
318
- pending-registration collection plus its TTL index.
381
+ Mount the router at `/auth` to use these routes:
382
+
383
+ | Method and route | Request body | Success | Notes |
384
+ | --- | --- | --- | --- |
385
+ | `POST /auth/potential-users` | `email` | `201` or `202` | Starts/refreshes registration verification. `202` is used when a sender callback is configured. |
386
+ | `POST /auth/register` | `email`, `password`, optional `verificationCode` | `201` | Code required when registration verification is enabled. |
387
+ | `POST /auth/token` | `email`, `password`, optional `twoFactorCode` | `200` | Returns access token and sets refresh-token cookie. |
388
+ | `POST /auth/refresh` | Optional `refresh_token` | `200` | Uses body token first, then the configured cookie. Issues a new refresh token. |
389
+ | `POST /auth/2fa/code` | `email`, `password` | `201` or `202` | Only useful when 2FA is enabled. |
390
+ | `PATCH /auth/password` | `newPassword`, optional `user_id` or `email` | `204` | Requires JWT; authorization settings can require admin access. |
391
+ | `POST /auth/roles` | `roles`, exactly one of `user_id` or `email` | `200` | Requires administrator access token. |
392
+ | `DELETE /auth/roles` | `role`, exactly one of `user_id` or `email` | `200` | Requires administrator access token. |
393
+ | `PUT /auth/roles` | `roles`, exactly one of `user_id` or `email` | `200` | Requires administrator access token. |
394
+
395
+ Access tokens include the application payload. Refresh tokens contain only the
396
+ subject and token metadata. The library does not persist refresh tokens or
397
+ revoke previously issued refresh tokens; the host application should add a
398
+ token-revocation strategy if that is required.
319
399
 
320
400
  ## Verifier-only service
321
401
 
322
- A business service that only verifies access tokens does not need MongoDB or a
402
+ A service that only validates access tokens needs no database adapter and no
323
403
  private key:
324
404
 
325
405
  ```js
406
+ import express from "express";
407
+ import { createAuthenticationLibrary } from "@nabhiraj/nab-auth";
408
+
409
+ const app = express();
326
410
  const auth = createAuthenticationLibrary({
327
411
  jwt: {
328
412
  issuer: "my-service",
329
413
  audience: "my-client",
330
414
  keys: {
331
- publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
415
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
332
416
  }
333
417
  }
334
418
  });
@@ -340,16 +424,39 @@ app.get("/api/business-data", auth.requireJwt, (req, res) => {
340
424
  app.use(auth.jwtErrorHandler);
341
425
  ```
342
426
 
427
+ Do not call `connectToDatabase()` or `createAuthRouter()` in this mode.
428
+
429
+ ## Testing
430
+
431
+ Run unit and adapter-contract tests:
432
+
433
+ ```bash
434
+ npm test
435
+ ```
436
+
437
+ Run the opt-in live MongoDB end-to-end suite only with dedicated test
438
+ credentials and an isolated test database:
439
+
440
+ ```bash
441
+ npm run test:e2e:mongo
442
+ ```
443
+
444
+ See [E2E_TEST_PLAN.md](E2E_TEST_PLAN.md) for required endpoint and
445
+ configuration coverage. Never point live tests at the host application’s
446
+ database.
447
+
343
448
  ## Production checklist
344
449
 
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.
450
+ - Keep database credentials and private keys outside source control.
451
+ - Use HTTPS and `refreshCookie.secure: true`.
452
+ - Set `codeDelivery.allowCodeInResponseWithoutSender: false`.
453
+ - Provide real code-delivery callbacks for enabled verification features.
454
+ - Add application-level rate limits to login, registration, code generation,
455
+ refresh, and password-change routes.
456
+ - Configure CORS and `sameSite` for the frontend topology.
457
+ - Retain old public keys until every token signed with them expires.
458
+ - Define refresh-token revocation if logout, breach response, or session
459
+ invalidation requires it.
353
460
 
354
461
  ## Support and contributing
355
462
 
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.0.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,13 @@ export function createAuthConfig(overrides = {}) {
15
15
  validateCodeConfig(config.twoFactor, "twoFactor", "required");
16
16
  validateString(config.authorization.adminRole, "authorization.adminRole");
17
17
 
18
+ if (
19
+ config.database.adapter !== undefined &&
20
+ typeof config.database.adapter !== "function"
21
+ ) {
22
+ throw new TypeError("database.adapter must be an adapter factory function");
23
+ }
24
+
18
25
  if (typeof config.codeDelivery.allowCodeInResponseWithoutSender !== "boolean") {
19
26
  throw new TypeError(
20
27
  "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",
@@ -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,
@@ -170,23 +171,24 @@ export function createAuthController({
170
171
  }
171
172
  }
172
173
 
173
- try {
174
- const userId = await repository.insertUser(
175
- normalizedEmail,
176
- await hashPassword(password, config.passwordHash)
177
- );
174
+ const userId = randomUUID();
175
+ const userCreated = await repository.createUser({
176
+ user_id: userId,
177
+ email: normalizedEmail,
178
+ passwordHash: await hashPassword(password, config.passwordHash),
179
+ nab_role: [],
180
+ createdAt: new Date()
181
+ });
178
182
 
179
- if (config.registration.verificationRequired) {
180
- await repository.deletePotentialUserByEmail(normalizedEmail);
181
- }
183
+ if (!userCreated) {
184
+ return res.status(409).json({ message: "User already exists" });
185
+ }
182
186
 
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;
187
+ if (config.registration.verificationRequired) {
188
+ await repository.deletePotentialUserByEmail(normalizedEmail);
189
189
  }
190
+
191
+ return res.status(201).json({ user_id: userId, email: normalizedEmail });
190
192
  } catch (error) {
191
193
  next(error);
192
194
  }
@@ -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,15 +37,27 @@ 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
@@ -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";
@@ -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
+ }