@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 +257 -150
- package/package.json +2 -2
- package/src/authenticationLogic/adapters/authStore.js +28 -0
- package/src/authenticationLogic/{repositories/userRepository.js → adapters/mongoAuthStore.js} +24 -60
- package/src/authenticationLogic/config/createAuthConfig.js +7 -0
- package/src/authenticationLogic/config/defaultAuthConfig.js +3 -5
- package/src/authenticationLogic/controllers/authController.js +17 -15
- package/src/authenticationLogic/createAuthenticationLibrary.js +35 -9
- package/src/authenticationLogic/index.js +2 -0
- package/src/authenticationLogic/services/defaultAdminService.js +27 -0
- package/src/authenticationLogic/utils/normalizeEmail.js +3 -0
package/README.md
CHANGED
|
@@ -2,32 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
Argon2id password hashing, RS256
|
|
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
|
|
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.
|
|
11
14
|
|
|
12
15
|
## Features
|
|
13
16
|
|
|
14
|
-
- Email/password registration and login with Argon2id
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
- Role claims and administrator-protected role
|
|
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
|
|
20
|
-
- Verifier-only mode for services that only
|
|
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
|
|
25
|
-
- Express 5
|
|
26
|
-
-
|
|
27
|
-
-
|
|
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
|
|
30
|
-
|
|
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 {
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
54
|
-
audience:
|
|
71
|
+
issuer: process.env.JWT_ISSUER,
|
|
72
|
+
audience: process.env.JWT_AUDIENCE,
|
|
55
73
|
keys: {
|
|
56
|
-
privateKeyPath: process.env.
|
|
57
|
-
publicKeyPath: process.env.
|
|
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
|
|
90
|
+
await emailProvider.send(email, `Your verification code is ${verificationCode}`);
|
|
73
91
|
},
|
|
74
92
|
sendTwoFactorCode: async ({ email, verificationCode }) => {
|
|
75
|
-
await
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
104
|
-
|
|
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()` |
|
|
110
|
-
| `closeDatabase()` | Closes
|
|
111
|
-
| `createAuthRouter(payloadFactory, options)` | Creates the Express authentication router. |
|
|
112
|
-
| `requireJwt` |
|
|
113
|
-
| `requireNabAdmin` | Requires the configured administrator role
|
|
114
|
-
| `jwtErrorHandler` |
|
|
115
|
-
| `config` | The merged configuration
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
`
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
142
|
-
publicKeyPath: process.env.
|
|
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@
|
|
180
|
-
password: "
|
|
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
|
-
|
|
187
|
-
to `
|
|
188
|
-
`false`;
|
|
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
|
|
318
|
+
`codeDelivery.allowCodeInResponseWithoutSender` controls a missing callback:
|
|
214
319
|
|
|
215
|
-
- `true
|
|
320
|
+
- `true`: returns the generated code in the HTTP response. Use only for local
|
|
216
321
|
development or automated tests.
|
|
217
|
-
- `false
|
|
218
|
-
|
|
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
|
|
325
|
+
## JWT keys and rotation
|
|
225
326
|
|
|
226
|
-
The built-in key provider loads JSON files lazily and
|
|
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
|
|
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
|
|
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
|
|
255
|
-
3. Keep the
|
|
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
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
377
|
+
When `jwt.keyProvider` is present, `jwt.keys` is not used.
|
|
282
378
|
|
|
283
379
|
## HTTP API
|
|
284
380
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
| Method and route | Request body |
|
|
288
|
-
| --- | --- | --- |
|
|
289
|
-
| `POST /auth/potential-users` | `email` |
|
|
290
|
-
| `POST /auth/register` | `email`, `password`, optional `verificationCode` |
|
|
291
|
-
| `POST /auth/token` | `email`, `password`, optional `twoFactorCode` |
|
|
292
|
-
| `POST /auth/refresh` | Optional `refresh_token` |
|
|
293
|
-
| `POST /auth/2fa/code` | `email`, `password` |
|
|
294
|
-
| `PATCH /auth/password` | `newPassword`, optional `user_id` or `email` |
|
|
295
|
-
| `POST /auth/roles` | `roles`, exactly one of `user_id` or `email` |
|
|
296
|
-
| `DELETE /auth/roles` | `role`, exactly one of `user_id` or `email` |
|
|
297
|
-
| `PUT /auth/roles` | `roles`, exactly one of `user_id` or `email` |
|
|
298
|
-
|
|
299
|
-
Access tokens include the application payload
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
346
|
-
-
|
|
347
|
-
-
|
|
348
|
-
-
|
|
349
|
-
- Add application-level rate limits to login, registration, code
|
|
350
|
-
and password-change routes.
|
|
351
|
-
- Configure CORS and
|
|
352
|
-
- Retain old public keys until
|
|
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.
|
|
4
|
-
"description": "Configurable Express
|
|
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
|
+
}
|
package/src/authenticationLogic/{repositories/userRepository.js → adapters/mongoAuthStore.js}
RENAMED
|
@@ -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
|
|
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
|
|
33
|
+
async connect() {
|
|
81
34
|
if (usersCollection) {
|
|
82
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 "../
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
183
|
+
if (!userCreated) {
|
|
184
|
+
return res.status(409).json({ message: "User already exists" });
|
|
185
|
+
}
|
|
182
186
|
|
|
183
|
-
|
|
184
|
-
|
|
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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
27
|
-
|
|
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
|
+
}
|