@nabhiraj/nab-auth 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nabhiraj
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,404 @@
1
- # Temporary Holding Version
1
+ # Nab Authentication
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
7
+
8
+ The library does not load environment variables or send email/SMS itself. The
9
+ application using it owns both configuration and code delivery.
10
+
11
+ ## Requirements
12
+
13
+ - Node.js 20 or later
14
+ - Express 5
15
+ - MongoDB or MongoDB Atlas
16
+ - An RSA key pair for RS256 JWT signing
17
+
18
+ `argon2` is a native dependency. Most supported systems use a prebuilt binary;
19
+ unusual platforms may need standard Node.js native build tools.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ npm install @nabhiraj/nab-authentication express
25
+ ```
26
+
27
+ Replace `@nabhiraj` if you publish the package under another npm scope.
28
+
29
+ ## Quick start
30
+
31
+ ```js
32
+ import "dotenv/config";
33
+ import express from "express";
34
+ import { createAuthenticationLibrary } from "@nabhiraj/nab-authentication";
35
+
36
+ const app = express();
37
+ app.use(express.json());
38
+
39
+ const auth = createAuthenticationLibrary({
40
+ mongo: {
41
+ uri: process.env.MY_AUTH_MONGO_URI,
42
+ databaseName: "my_auth_database"
43
+ },
44
+ jwt: {
45
+ issuer: "my-service",
46
+ audience: "my-client",
47
+ keys: {
48
+ privateKeyPath: process.env.MY_JWT_PRIVATE_KEY_PATH,
49
+ publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
50
+ }
51
+ },
52
+ defaultAdmin: { enabled: false }
53
+ });
54
+
55
+ const createTokenPayload = (user) => ({
56
+ email: user.email,
57
+ nab_role: user.nab_role || []
58
+ });
59
+
60
+ app.use(
61
+ "/auth",
62
+ auth.createAuthRouter(createTokenPayload, {
63
+ sendRegistrationCode: async ({ email, verificationCode }) => {
64
+ await sendEmail(email, `Your verification code is ${verificationCode}`);
65
+ },
66
+ sendTwoFactorCode: async ({ email, verificationCode }) => {
67
+ await sendEmail(email, `Your 2FA code is ${verificationCode}`);
68
+ }
69
+ })
70
+ );
71
+
72
+ app.get("/api/private", auth.requireJwt, (req, res) => {
73
+ res.json({ userId: req.auth.sub });
74
+ });
75
+
76
+ app.use(auth.jwtErrorHandler);
77
+ await auth.connectToDatabase();
78
+ app.listen(3000);
79
+ ```
80
+
81
+ ## Public API
82
+
83
+ ```js
84
+ import {
85
+ createAuthenticationLibrary,
86
+ createFileJwtKeyProvider,
87
+ defaultAuthConfig
88
+ } from "@nabhiraj/nab-authentication";
89
+ ```
90
+
91
+ `createAuthenticationLibrary(config)` creates isolated state: its MongoDB
92
+ client, JWT-key cache, middleware, and router do not share state with another
93
+ instance.
94
+
95
+ | Member | Purpose |
96
+ | --- | --- |
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. |
104
+
105
+ `payloadFactory(user)` creates application-specific access-token claims. The
106
+ library adds standard JWT claims, `sub`, and `tokenType: "access"`.
107
+
108
+ ## Configuration
109
+
110
+ Every setting is optional unless a feature needs it. Omitted values use
111
+ `defaultAuthConfig`.
112
+
113
+ ```js
114
+ const auth = createAuthenticationLibrary({
115
+ mongo: {
116
+ uri: process.env.MY_AUTH_MONGO_URI,
117
+ databaseName: "auth_db",
118
+ usersCollectionName: "users",
119
+ potentialUsersCollectionName: "potential_user"
120
+ },
121
+ jwt: {
122
+ issuer: "my-service",
123
+ audience: "my-client",
124
+ algorithm: "RS256",
125
+ accessTokenTtl: "15m",
126
+ refreshTokenTtl: "7d",
127
+ keyCacheTtlMs: 15 * 60 * 1000,
128
+ keys: {
129
+ privateKeyPath: process.env.MY_PRIVATE_KEY_PATH,
130
+ publicKeyPath: process.env.MY_PUBLIC_KEY_PATH
131
+ }
132
+ },
133
+ authorization: {
134
+ adminRole: "nab_admin",
135
+ userRegistrationRequiresNabAdmin: false,
136
+ passwordChangeRequiresNabAdmin: false,
137
+ nabAdminCanChangeOtherUsersPasswords: true
138
+ },
139
+ registration: {
140
+ verificationRequired: false,
141
+ codeDigits: 6,
142
+ codeTtlMs: 10 * 60 * 1000
143
+ },
144
+ twoFactor: {
145
+ required: false,
146
+ codeDigits: 6,
147
+ codeTtlMs: 10 * 60 * 1000
148
+ },
149
+ passwordHash: {
150
+ memoryCost: 19456,
151
+ timeCost: 2,
152
+ parallelism: 1
153
+ },
154
+ refreshCookie: {
155
+ name: "refresh_token",
156
+ httpOnly: true,
157
+ secure: true,
158
+ sameSite: "lax",
159
+ path: "/auth/refresh",
160
+ maxAgeMs: 7 * 24 * 60 * 60 * 1000
161
+ },
162
+ codeDelivery: {
163
+ allowCodeInResponseWithoutSender: false
164
+ },
165
+ defaultAdmin: {
166
+ enabled: false,
167
+ email: "admin@admin.com",
168
+ password: "change-this",
169
+ roles: ["nab_admin"]
170
+ }
171
+ });
172
+ ```
173
+
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.
177
+
178
+ ### Code delivery callbacks
179
+
180
+ ```js
181
+ auth.createAuthRouter(createTokenPayload, {
182
+ sendRegistrationCode: async ({
183
+ email,
184
+ verificationCode,
185
+ codeGeneratedAt,
186
+ codeExpiresAt
187
+ }) => {
188
+ await emailProvider.send(email, verificationCode);
189
+ },
190
+ sendTwoFactorCode: async ({
191
+ email,
192
+ verificationCode,
193
+ codeGeneratedAt,
194
+ codeExpiresAt
195
+ }) => {
196
+ await smsProvider.send(email, verificationCode);
197
+ }
198
+ });
199
+ ```
200
+
201
+ `codeDelivery.allowCodeInResponseWithoutSender` controls missing callbacks:
202
+
203
+ - `true` returns the generated code in the HTTP response. Use only for local
204
+ 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.
211
+
212
+ ## JWT key files and rotation
213
+
214
+ The built-in key provider loads JSON files lazily and refreshes its cache after
215
+ `jwt.keyCacheTtlMs`.
216
+
217
+ Private-key JSON has one active signing key:
218
+
219
+ ```json
220
+ {
221
+ "kid": "101",
222
+ "privateKey": "-----BEGIN PRIVATE KEY-----..."
223
+ }
224
+ ```
225
+
226
+ Public-key JSON keeps each currently valid verification key:
227
+
228
+ ```json
229
+ {
230
+ "keys": [
231
+ {
232
+ "kid": "101",
233
+ "publicKey": "-----BEGIN PUBLIC KEY-----..."
234
+ }
235
+ ]
236
+ }
237
+ ```
238
+
239
+ Rotation process:
240
+
241
+ 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.
246
+
247
+ ### Custom key provider
248
+
249
+ Use a key provider for KMS, Vault, or a secret manager:
250
+
251
+ ```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
+ }
264
+ }
265
+ }
266
+ });
267
+ ```
268
+
269
+ When `jwt.keyProvider` is supplied, the file paths are not used.
270
+
271
+ ## Routes
272
+
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. |
286
+
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.
290
+
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.
307
+
308
+ ## Verifier-only service
309
+
310
+ A business service that only verifies access tokens does not need MongoDB or a
311
+ private key:
312
+
313
+ ```js
314
+ const auth = createAuthenticationLibrary({
315
+ jwt: {
316
+ issuer: "my-service",
317
+ audience: "my-client",
318
+ keys: {
319
+ publicKeyPath: process.env.MY_JWT_PUBLIC_KEY_PATH
320
+ }
321
+ }
322
+ });
323
+
324
+ app.get("/api/business-data", auth.requireJwt, (req, res) => {
325
+ res.json({ userId: req.auth.sub });
326
+ });
327
+
328
+ app.use(auth.jwtErrorHandler);
329
+ ```
330
+
331
+ ## Security notes
332
+
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.
341
+
342
+ ## Development and testing
343
+
344
+ ```bash
345
+ npm install
346
+ npm test
347
+ ```
348
+
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:
392
+
393
+ ```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
398
+ ```
399
+
400
+ The package is pre-1.0.0, so document any breaking public API changes.
401
+
402
+ ## License
403
+
404
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@nabhiraj/nab-auth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Configurable Express, MongoDB, JWT, registration verification, and 2FA authentication library",
5
+ "type": "module",
6
+ "main": "./src/authenticationLogic/index.js",
7
+ "exports": {
8
+ ".": "./src/authenticationLogic/index.js"
9
+ },
10
+ "files": [
11
+ "src/authenticationLogic",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "keywords": [
19
+ "authentication",
20
+ "express",
21
+ "jwt",
22
+ "mongodb",
23
+ "argon2",
24
+ "2fa"
25
+ ],
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test",
32
+ "pack-check": "npm pack --dry-run"
33
+ },
34
+ "dependencies": {
35
+ "argon2": "^0.45.1",
36
+ "cookie": "^2.0.1",
37
+ "express-jwt": "^8.5.1",
38
+ "jsonwebtoken": "^9.0.2",
39
+ "mongodb": "^7.7.0"
40
+ },
41
+ "peerDependencies": {
42
+ "express": "^5.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "express": "^5.1.0"
46
+ }
47
+ }
@@ -0,0 +1,94 @@
1
+ import { defaultAuthConfig } from "./defaultAuthConfig.js";
2
+
3
+ export function createAuthConfig(overrides = {}) {
4
+ const config = mergeConfig(defaultAuthConfig, overrides);
5
+
6
+ validateString(config.jwt.issuer, "jwt.issuer");
7
+ validateString(config.jwt.audience, "jwt.audience");
8
+ validateString(config.jwt.algorithm, "jwt.algorithm");
9
+ validatePositiveNumber(config.jwt.keyCacheTtlMs, "jwt.keyCacheTtlMs");
10
+ validateCodeConfig(
11
+ config.registration,
12
+ "registration",
13
+ "verificationRequired"
14
+ );
15
+ validateCodeConfig(config.twoFactor, "twoFactor", "required");
16
+ validateString(config.authorization.adminRole, "authorization.adminRole");
17
+
18
+ if (typeof config.codeDelivery.allowCodeInResponseWithoutSender !== "boolean") {
19
+ throw new TypeError(
20
+ "codeDelivery.allowCodeInResponseWithoutSender must be a boolean"
21
+ );
22
+ }
23
+
24
+ if (config.jwt.keyProvider !== undefined) {
25
+ if (
26
+ typeof config.jwt.keyProvider.getActiveSigningKey !== "function" ||
27
+ typeof config.jwt.keyProvider.getPublicKey !== "function"
28
+ ) {
29
+ throw new TypeError(
30
+ "jwt.keyProvider must provide getActiveSigningKey and getPublicKey functions"
31
+ );
32
+ }
33
+ }
34
+
35
+ return config;
36
+ }
37
+
38
+ function mergeConfig(defaults, overrides) {
39
+ const merged = mergeDefined(defaults, overrides);
40
+
41
+ for (const key of Object.keys(defaults)) {
42
+ if (isPlainObject(defaults[key])) {
43
+ merged[key] = mergeDefined(defaults[key], overrides[key]);
44
+ }
45
+ }
46
+
47
+ merged.jwt.keys = mergeDefined(defaults.jwt.keys, overrides.jwt?.keys);
48
+
49
+ return merged;
50
+ }
51
+
52
+ function mergeDefined(defaults, overrides) {
53
+ const merged = { ...defaults };
54
+
55
+ if (!isPlainObject(overrides)) {
56
+ return merged;
57
+ }
58
+
59
+ for (const [key, value] of Object.entries(overrides)) {
60
+ if (value !== undefined) {
61
+ merged[key] = value;
62
+ }
63
+ }
64
+
65
+ return merged;
66
+ }
67
+
68
+ function validateCodeConfig(config, name, enabledField) {
69
+ if (typeof config[enabledField] !== "boolean") {
70
+ throw new TypeError(`${name}.${enabledField} must be a boolean`);
71
+ }
72
+
73
+ if (!Number.isInteger(config.codeDigits) || config.codeDigits < 1 || config.codeDigits > 9) {
74
+ throw new TypeError(`${name}.codeDigits must be an integer from 1 to 9`);
75
+ }
76
+
77
+ validatePositiveNumber(config.codeTtlMs, `${name}.codeTtlMs`);
78
+ }
79
+
80
+ function validateString(value, name) {
81
+ if (typeof value !== "string" || !value) {
82
+ throw new TypeError(`${name} must be a non-empty string`);
83
+ }
84
+ }
85
+
86
+ function validatePositiveNumber(value, name) {
87
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
88
+ throw new TypeError(`${name} must be a positive number`);
89
+ }
90
+ }
91
+
92
+ function isPlainObject(value) {
93
+ return value !== null && typeof value === "object" && !Array.isArray(value);
94
+ }
@@ -0,0 +1,59 @@
1
+ export const defaultAuthConfig = {
2
+ mongo: {
3
+ uri: undefined,
4
+ databaseName: "jwt_auth_demo",
5
+ usersCollectionName: "users",
6
+ potentialUsersCollectionName: "potential_user"
7
+ },
8
+ jwt: {
9
+ issuer: "jwt-express-demo",
10
+ audience: "jwt-express-demo-client",
11
+ algorithm: "RS256",
12
+ accessTokenTtl: "15m",
13
+ refreshTokenTtl: "7d",
14
+ keyCacheTtlMs: 15 * 60 * 1000,
15
+ keys: {
16
+ privateKeyPath: undefined,
17
+ publicKeyPath: undefined
18
+ },
19
+ keyProvider: undefined
20
+ },
21
+ authorization: {
22
+ adminRole: "nab_admin",
23
+ userRegistrationRequiresNabAdmin: false,
24
+ passwordChangeRequiresNabAdmin: false,
25
+ nabAdminCanChangeOtherUsersPasswords: true
26
+ },
27
+ registration: {
28
+ verificationRequired: false,
29
+ codeDigits: 6,
30
+ codeTtlMs: 10 * 60 * 1000
31
+ },
32
+ twoFactor: {
33
+ required: false,
34
+ codeDigits: 6,
35
+ codeTtlMs: 10 * 60 * 1000
36
+ },
37
+ passwordHash: {
38
+ memoryCost: 19456,
39
+ timeCost: 2,
40
+ parallelism: 1
41
+ },
42
+ refreshCookie: {
43
+ name: "refresh_token",
44
+ httpOnly: true,
45
+ secure: false,
46
+ sameSite: "lax",
47
+ path: "/auth/refresh",
48
+ maxAgeMs: 7 * 24 * 60 * 60 * 1000
49
+ },
50
+ codeDelivery: {
51
+ allowCodeInResponseWithoutSender: true
52
+ },
53
+ defaultAdmin: {
54
+ enabled: false,
55
+ email: "admin@admin.com",
56
+ password: "admin",
57
+ roles: ["nab_admin"]
58
+ }
59
+ };