@nabhiraj/nab-auth 0.1.0 → 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
@@ -1,52 +1,78 @@
1
- # Nab Authentication
1
+ # nab-auth
2
2
 
3
- Configurable authentication for Express applications using MongoDB, Argon2id,
4
- and RS256 JWTs. It includes registration, password login, refresh tokens,
5
- roles, optional registration verification, and optional two-factor
6
- authentication.
3
+ [![license](https://img.shields.io/npm/l/@nabhiraj/nab-auth.svg)](LICENSE)
7
4
 
8
- The library does not load environment variables or send email/SMS itself. The
9
- application using it owns both configuration and code delivery.
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.
10
+
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.
14
+
15
+ ## Features
16
+
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
21
+ - Optional registration-code verification and two-factor authentication
22
+ - Explicit database adapters; MongoDB/Atlas adapter included
23
+ - Verifier-only mode for services that only validate access tokens
10
24
 
11
25
  ## Requirements
12
26
 
13
- - Node.js 20 or later
14
- - Express 5
15
- - MongoDB or MongoDB Atlas
16
- - 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
17
32
 
18
- `argon2` is a native dependency. Most supported systems use a prebuilt binary;
19
- 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.
20
35
 
21
36
  ## Installation
22
37
 
23
38
  ```bash
24
- npm install @nabhiraj/nab-authentication express
39
+ npm install @nabhiraj/nab-auth express
25
40
  ```
26
41
 
27
- Replace `@nabhiraj` if you publish the package under another npm scope.
42
+ ## Quick start: MongoDB
28
43
 
29
- ## Quick start
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.
30
47
 
31
48
  ```js
32
49
  import "dotenv/config";
33
50
  import express from "express";
34
- import { createAuthenticationLibrary } from "@nabhiraj/nab-authentication";
51
+ import {
52
+ createAuthenticationLibrary,
53
+ createMongoAuthStore
54
+ } from "@nabhiraj/nab-auth";
35
55
 
36
56
  const app = express();
37
57
  app.use(express.json());
38
58
 
39
59
  const auth = createAuthenticationLibrary({
40
- mongo: {
41
- uri: process.env.MY_AUTH_MONGO_URI,
42
- 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
+ }
43
69
  },
44
70
  jwt: {
45
- issuer: "my-service",
46
- audience: "my-client",
71
+ issuer: process.env.JWT_ISSUER,
72
+ audience: process.env.JWT_AUDIENCE,
47
73
  keys: {
48
- privateKeyPath: process.env.MY_JWT_PRIVATE_KEY_PATH,
49
- publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
74
+ privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH,
75
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
50
76
  }
51
77
  },
52
78
  defaultAdmin: { enabled: false }
@@ -61,10 +87,10 @@ app.use(
61
87
  "/auth",
62
88
  auth.createAuthRouter(createTokenPayload, {
63
89
  sendRegistrationCode: async ({ email, verificationCode }) => {
64
- await sendEmail(email, `Your verification code is ${verificationCode}`);
90
+ await emailProvider.send(email, `Your verification code is ${verificationCode}`);
65
91
  },
66
92
  sendTwoFactorCode: async ({ email, verificationCode }) => {
67
- await sendEmail(email, `Your 2FA code is ${verificationCode}`);
93
+ await emailProvider.send(email, `Your 2FA code is ${verificationCode}`);
68
94
  }
69
95
  })
70
96
  );
@@ -78,45 +104,139 @@ await auth.connectToDatabase();
78
104
  app.listen(3000);
79
105
  ```
80
106
 
81
- ## Public API
107
+ The order matters: call `connectToDatabase()` before receiving requests, and
108
+ mount `jwtErrorHandler` after routes that use `requireJwt`.
109
+
110
+ ## Core API
82
111
 
83
112
  ```js
84
113
  import {
114
+ assertAuthStore,
115
+ authStoreMethods,
85
116
  createAuthenticationLibrary,
86
117
  createFileJwtKeyProvider,
118
+ createMongoAuthStore,
87
119
  defaultAuthConfig
88
- } from "@nabhiraj/nab-authentication";
120
+ } from "@nabhiraj/nab-auth";
89
121
  ```
90
122
 
91
- `createAuthenticationLibrary(config)` creates isolated state: its MongoDB
92
- 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
93
125
  instance.
94
126
 
95
127
  | Member | Purpose |
96
128
  | --- | --- |
97
- | `connectToDatabase()` | Connects to MongoDB and creates configured collections/indexes. |
98
- | `closeDatabase()` | Closes this instance's MongoDB client. |
99
- | `createAuthRouter(payloadFactory, options)` | Creates the Express authentication router. |
100
- | `requireJwt` | Verifies access tokens and sets claims on `req.auth`. |
101
- | `requireNabAdmin` | Requires the configured administrator role. |
102
- | `jwtErrorHandler` | Express JWT error middleware; mount after protected routes. |
103
- | `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:
104
206
 
105
- `payloadFactory(user)` creates application-specific access-token claims. The
106
- 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.
107
223
 
108
224
  ## Configuration
109
225
 
110
- Every setting is optional unless a feature needs it. Omitted values use
111
- `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.
112
229
 
113
230
  ```js
114
231
  const auth = createAuthenticationLibrary({
115
- mongo: {
116
- uri: process.env.MY_AUTH_MONGO_URI,
117
- databaseName: "auth_db",
118
- usersCollectionName: "users",
119
- 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
+ }
120
240
  },
121
241
  jwt: {
122
242
  issuer: "my-service",
@@ -126,8 +246,8 @@ const auth = createAuthenticationLibrary({
126
246
  refreshTokenTtl: "7d",
127
247
  keyCacheTtlMs: 15 * 60 * 1000,
128
248
  keys: {
129
- privateKeyPath: process.env.MY_PRIVATE_KEY_PATH,
130
- publicKeyPath: process.env.MY_PUBLIC_KEY_PATH
249
+ privateKeyPath: process.env.JWT_PRIVATE_KEY_PATH,
250
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
131
251
  }
132
252
  },
133
253
  authorization: {
@@ -146,11 +266,6 @@ const auth = createAuthenticationLibrary({
146
266
  codeDigits: 6,
147
267
  codeTtlMs: 10 * 60 * 1000
148
268
  },
149
- passwordHash: {
150
- memoryCost: 19456,
151
- timeCost: 2,
152
- parallelism: 1
153
- },
154
269
  refreshCookie: {
155
270
  name: "refresh_token",
156
271
  httpOnly: true,
@@ -164,57 +279,55 @@ const auth = createAuthenticationLibrary({
164
279
  },
165
280
  defaultAdmin: {
166
281
  enabled: false,
167
- email: "admin@admin.com",
168
- password: "change-this",
282
+ email: "admin@example.com",
283
+ password: "use-a-strong-secret",
169
284
  roles: ["nab_admin"]
170
285
  }
171
286
  });
172
287
  ```
173
288
 
174
- Use `refreshCookie.secure: true` for HTTPS production deployments. It defaults
175
- to `false` only for local HTTP development. `defaultAdmin.enabled` defaults to
176
- `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. |
177
302
 
178
303
  ### Code delivery callbacks
179
304
 
305
+ Supply callbacks only if the associated feature is enabled:
306
+
180
307
  ```js
181
308
  auth.createAuthRouter(createTokenPayload, {
182
- sendRegistrationCode: async ({
183
- email,
184
- verificationCode,
185
- codeGeneratedAt,
186
- codeExpiresAt
187
- }) => {
309
+ sendRegistrationCode: async ({ email, verificationCode }) => {
188
310
  await emailProvider.send(email, verificationCode);
189
311
  },
190
- sendTwoFactorCode: async ({
191
- email,
192
- verificationCode,
193
- codeGeneratedAt,
194
- codeExpiresAt
195
- }) => {
312
+ sendTwoFactorCode: async ({ email, verificationCode }) => {
196
313
  await smsProvider.send(email, verificationCode);
197
314
  }
198
315
  });
199
316
  ```
200
317
 
201
- `codeDelivery.allowCodeInResponseWithoutSender` controls missing callbacks:
318
+ `codeDelivery.allowCodeInResponseWithoutSender` controls a missing callback:
202
319
 
203
- - `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
204
321
  development or automated tests.
205
- - `false` rejects the request when the required sender callback is absent. Use
206
- this in production.
207
-
208
- Registration codes are stored as Argon2id hashes in `potential_user` and are
209
- removed by a MongoDB TTL index. 2FA code hashes are stored on the user and are
210
- atomically consumed after a successful token issue.
322
+ - `false`: the route fails when a required callback is absent. Use this in
323
+ production.
211
324
 
212
- ## JWT key files and rotation
325
+ ## JWT keys and rotation
213
326
 
214
- 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
215
328
  `jwt.keyCacheTtlMs`.
216
329
 
217
- Private-key JSON has one active signing key:
330
+ Private-key JSON contains the active signing key:
218
331
 
219
332
  ```json
220
333
  {
@@ -223,7 +336,7 @@ Private-key JSON has one active signing key:
223
336
  }
224
337
  ```
225
338
 
226
- Public-key JSON keeps each currently valid verification key:
339
+ Public-key JSON contains every currently valid verification key:
227
340
 
228
341
  ```json
229
342
  {
@@ -239,84 +352,67 @@ Public-key JSON keeps each currently valid verification key:
239
352
  Rotation process:
240
353
 
241
354
  1. Add the new public key and `kid` to the public-key JSON.
242
- 2. Switch the active private-key JSON to the matching new key and `kid`.
243
- 3. Keep the previous public key until all tokens signed by it have expired.
244
-
245
- Never commit or publish private keys.
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.
246
357
 
247
- ### Custom key provider
248
-
249
- 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:
250
360
 
251
361
  ```js
252
- const auth = createAuthenticationLibrary({
253
- jwt: {
254
- keyProvider: {
255
- async getActiveSigningKey() {
256
- return {
257
- keyId: "101",
258
- privateKey: await secretManager.getActivePrivateKey()
259
- };
260
- },
261
- async getPublicKey(keyId) {
262
- return secretManager.getPublicKey(keyId);
263
- }
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);
264
372
  }
265
373
  }
266
- });
374
+ }
267
375
  ```
268
376
 
269
- When `jwt.keyProvider` is supplied, the file paths are not used.
377
+ When `jwt.keyProvider` is present, `jwt.keys` is not used.
270
378
 
271
- ## Routes
379
+ ## HTTP API
272
380
 
273
- Assume the router is mounted at `/auth`.
274
-
275
- | Method and route | Request body | Description |
276
- | --- | --- | --- |
277
- | `POST /auth/potential-users` | `email` | Generates or refreshes a registration code for an unregistered email. |
278
- | `POST /auth/register` | `email`, `password`, optional `verificationCode` | Creates a user. The code is required when verification is enabled. |
279
- | `POST /auth/token` | `email`, `password`, optional `twoFactorCode` | Issues an access token and refresh-token cookie. |
280
- | `POST /auth/refresh` | Optional `refresh_token` | Returns a new access token and refresh-token cookie; falls back to cookie. |
281
- | `POST /auth/2fa/code` | `email`, `password` | Generates or refreshes a 2FA code when 2FA is enabled. |
282
- | `PATCH /auth/password` | `newPassword`, optional `user_id` or `email` | Changes own password, or another user's if policy allows. |
283
- | `POST /auth/roles` | `roles`, exactly one of `user_id` or `email` | Adds roles. Requires administrator access token. |
284
- | `DELETE /auth/roles` | `role`, exactly one of `user_id` or `email` | Removes one role. Requires administrator access token. |
285
- | `PUT /auth/roles` | `roles`, exactly one of `user_id` or `email` | Replaces all roles. Requires administrator access token. |
381
+ Mount the router at `/auth` to use these routes:
286
382
 
287
- Access tokens include the application payload and access-token metadata.
288
- Refresh tokens contain only the subject and required token metadata, not the
289
- complete application payload.
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. |
290
394
 
291
- ## MongoDB documents
292
-
293
- The users collection contains documents like:
294
-
295
- ```json
296
- {
297
- "user_id": "2d6c2f40-0a3f-4b2e-9d8c-8f3b7f6e1a20",
298
- "email": "alice@example.com",
299
- "passwordHash": "<Argon2id hash>",
300
- "nab_role": ["editor"],
301
- "createdAt": "2026-01-01T00:00:00.000Z"
302
- }
303
- ```
304
-
305
- The library creates unique indexes for `email` and `user_id`, and creates the
306
- pending-registration collection plus its TTL index.
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.
307
399
 
308
400
  ## Verifier-only service
309
401
 
310
- 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
311
403
  private key:
312
404
 
313
405
  ```js
406
+ import express from "express";
407
+ import { createAuthenticationLibrary } from "@nabhiraj/nab-auth";
408
+
409
+ const app = express();
314
410
  const auth = createAuthenticationLibrary({
315
411
  jwt: {
316
412
  issuer: "my-service",
317
413
  audience: "my-client",
318
414
  keys: {
319
- publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
415
+ publicKeyPath: process.env.JWT_PUBLIC_KEY_PATH
320
416
  }
321
417
  }
322
418
  });
@@ -328,76 +424,46 @@ app.get("/api/business-data", auth.requireJwt, (req, res) => {
328
424
  app.use(auth.jwtErrorHandler);
329
425
  ```
330
426
 
331
- ## Security notes
427
+ Do not call `connectToDatabase()` or `createAuthRouter()` in this mode.
332
428
 
333
- - Use HTTPS and `refreshCookie.secure: true` in production.
334
- - Set `codeDelivery.allowCodeInResponseWithoutSender: false` in production.
335
- - Provide real email/SMS delivery callbacks for codes.
336
- - Keep private keys and MongoDB credentials outside source control.
337
- - Add application-level rate limits to login, registration, code-generation,
338
- and password-change routes.
339
- - Configure CORS and cookie `sameSite` settings for your frontend topology.
340
- - Retain old public keys until tokens signed by them expire.
429
+ ## Testing
341
430
 
342
- ## Development and testing
431
+ Run unit and adapter-contract tests:
343
432
 
344
433
  ```bash
345
- npm install
346
434
  npm test
347
435
  ```
348
436
 
349
- The demo servers in `src/server.js` and `src/server2.js` are intentionally not
350
- published in the npm package. If you run those examples from this repository,
351
- install and load their example-only dependencies separately.
352
-
353
- Run automated tests:
354
-
355
- ```bash
356
- npm test
357
- ```
358
-
359
- Before publishing, inspect the exact package contents:
360
-
361
- ```bash
362
- npm run pack-check
363
- ```
364
-
365
- Test the generated archive from a separate consumer project:
366
-
367
- ```bash
368
- npm pack
369
- mkdir ../auth-package-consumer
370
- cd ../auth-package-consumer
371
- npm init -y
372
- npm install ../nab_authenticate/nabhiraj-nab-authentication-0.1.0.tgz express
373
- ```
374
-
375
- Create a small Express app there and verify import, database connection,
376
- router creation, and JWT verification.
377
-
378
- ## Publishing
379
-
380
- 1. Create an npm account and check that the package name is available.
381
- 2. Run `npm test` and `npm run pack-check`.
382
- 3. Confirm that `.env`, key files, and demo applications are not in the pack
383
- listing.
384
- 4. Authenticate with `npm login`.
385
- 5. Publish:
386
-
387
- ```bash
388
- npm publish
389
- ```
390
-
391
- For later releases:
437
+ Run the opt-in live MongoDB end-to-end suite only with dedicated test
438
+ credentials and an isolated test database:
392
439
 
393
440
  ```bash
394
- npm version patch # 0.1.0 -> 0.1.1
395
- npm version minor # 0.1.0 -> 0.2.0
396
- npm version major # 0.1.0 -> 1.0.0
397
- npm publish
441
+ npm run test:e2e:mongo
398
442
  ```
399
443
 
400
- The package is pre-1.0.0, so document any breaking public API changes.
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
+
448
+ ## Production checklist
449
+
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.
460
+
461
+ ## Support and contributing
462
+
463
+ Source code, bug reports, and feature requests are managed in the
464
+ [GitLab project](https://gitlab.com/platforms8851042/nab_authenticate).
465
+ Please use [GitLab Issues](https://gitlab.com/platforms8851042/nab_authenticate/-/issues)
466
+ for reproducible bugs and feature proposals.
401
467
 
402
468
  ## License
403
469
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nabhiraj/nab-auth",
3
- "version": "0.1.0",
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
+ }