sentri 2.1.0 → 4.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 +165 -17
- package/dist/index.d.ts +71 -17
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Auth and authorization library for Express. Supports two modes:
|
|
|
14
14
|
- [Server Mode](#server-mode)
|
|
15
15
|
- [Client Mode](#client-mode)
|
|
16
16
|
- [SSO Flow](#sso-flow)
|
|
17
|
+
- [Multi-Identifier](#multi-identifier)
|
|
17
18
|
- [Configuration](#configuration)
|
|
18
19
|
- [Endpoints](#endpoints)
|
|
19
20
|
- [Middleware](#middleware)
|
|
@@ -165,7 +166,8 @@ createAuth({
|
|
|
165
166
|
isTokenRevoked: async (sessionId) => await redis.sismember('revoked', sessionId),
|
|
166
167
|
router: { // override built-in service functions
|
|
167
168
|
login, register, refresh, logout, logoutAll, assignRoles,
|
|
168
|
-
|
|
169
|
+
bulkCreateIdentifiers, bulkUpdateIdentifiers, bulkDeleteIdentifiers,
|
|
170
|
+
changePrimaryIdentifier, changePassword,
|
|
169
171
|
},
|
|
170
172
|
});
|
|
171
173
|
```
|
|
@@ -177,9 +179,10 @@ createAuth({
|
|
|
177
179
|
await auth.migrate();
|
|
178
180
|
```
|
|
179
181
|
|
|
180
|
-
Creates
|
|
181
|
-
- `sentri_users` — id,
|
|
182
|
+
Creates three tables:
|
|
183
|
+
- `sentri_users` — id, password_hash, roles (JSON), created_at
|
|
182
184
|
- `sentri_sessions` — id, user_id, expires_at, created_at
|
|
185
|
+
- `sentri_identifiers` — id, user_id, type, value (globally unique), is_primary, created_at
|
|
183
186
|
|
|
184
187
|
---
|
|
185
188
|
|
|
@@ -223,6 +226,143 @@ Client apps point `keyUri` at this endpoint and receive the public key automatic
|
|
|
223
226
|
|
|
224
227
|
---
|
|
225
228
|
|
|
229
|
+
## Multi-Identifier
|
|
230
|
+
|
|
231
|
+
Each user can have multiple identifiers — email, username, phone number, or any custom type. All identifier values are **globally unique** regardless of type.
|
|
232
|
+
|
|
233
|
+
One identifier per user is marked as **primary** and its value is embedded in the JWT payload. Regardless of which identifier a user logs in with, the JWT always contains the primary identifier.
|
|
234
|
+
|
|
235
|
+
### Registration
|
|
236
|
+
|
|
237
|
+
Provide at least one identifier. The first entry becomes the primary.
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
POST /register
|
|
241
|
+
Content-Type: application/json
|
|
242
|
+
|
|
243
|
+
{
|
|
244
|
+
"identifiers": [
|
|
245
|
+
{ "type": "email", "value": "rizz@example.com" },
|
|
246
|
+
{ "type": "username", "value": "rizz" }
|
|
247
|
+
],
|
|
248
|
+
"password": "secret123",
|
|
249
|
+
"roles": ["user"]
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Response:**
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"error": false,
|
|
257
|
+
"statusCode": 201,
|
|
258
|
+
"message": "User registered successfully",
|
|
259
|
+
"data": {
|
|
260
|
+
"user": {
|
|
261
|
+
"id": "uuid",
|
|
262
|
+
"identifier": "rizz@example.com",
|
|
263
|
+
"identifierType": "email",
|
|
264
|
+
"roles": ["user"],
|
|
265
|
+
"identifiers": [
|
|
266
|
+
{ "id": "uuid-1", "type": "email", "value": "rizz@example.com", "isPrimary": true },
|
|
267
|
+
{ "id": "uuid-2", "type": "username", "value": "rizz", "isPrimary": false }
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Login
|
|
275
|
+
|
|
276
|
+
Send any of the user's identifier values — Sentri searches all types automatically.
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
POST /login
|
|
280
|
+
Content-Type: application/json
|
|
281
|
+
|
|
282
|
+
{ "identifier": "rizz", "password": "secret123" }
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Bulk Create Identifiers
|
|
286
|
+
|
|
287
|
+
```
|
|
288
|
+
POST /me/identifiers
|
|
289
|
+
Authorization: Bearer <token>
|
|
290
|
+
Content-Type: application/json
|
|
291
|
+
|
|
292
|
+
{
|
|
293
|
+
"identifiers": [
|
|
294
|
+
{ "type": "phone", "value": "+628123456789" }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Bulk Update Identifiers
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
PUT /me/identifiers
|
|
303
|
+
Authorization: Bearer <token>
|
|
304
|
+
Content-Type: application/json
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
"identifiers": [
|
|
308
|
+
{ "id": "uuid-2", "type": "username", "value": "newrizz" }
|
|
309
|
+
]
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Bulk Delete Identifiers
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
DELETE /me/identifiers
|
|
317
|
+
Authorization: Bearer <token>
|
|
318
|
+
Content-Type: application/json
|
|
319
|
+
|
|
320
|
+
{ "ids": ["uuid-2"] }
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
At least one identifier must remain after deletion.
|
|
324
|
+
|
|
325
|
+
### Change Primary Identifier
|
|
326
|
+
|
|
327
|
+
```
|
|
328
|
+
PATCH /me/identifiers/primary
|
|
329
|
+
Authorization: Bearer <token>
|
|
330
|
+
Content-Type: application/json
|
|
331
|
+
|
|
332
|
+
{ "id": "uuid-2" }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The new primary value will be embedded in the JWT on the next login or token refresh.
|
|
336
|
+
|
|
337
|
+
### Programmatic API
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
const auth = createAuth({ mode: 'server', ... });
|
|
341
|
+
|
|
342
|
+
// Register with multiple identifiers
|
|
343
|
+
await auth.register({
|
|
344
|
+
identifiers: [
|
|
345
|
+
{ type: 'email', value: 'rizz@example.com' },
|
|
346
|
+
{ type: 'username', value: 'rizz' },
|
|
347
|
+
],
|
|
348
|
+
password: 'secret123',
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Add identifiers after registration
|
|
352
|
+
await auth.bulkCreateIdentifiers(userId, [{ type: 'phone', value: '+628123456789' }]);
|
|
353
|
+
|
|
354
|
+
// Update identifiers
|
|
355
|
+
await auth.bulkUpdateIdentifiers(userId, [{ id: 'uuid-2', type: 'username', value: 'newrizz' }]);
|
|
356
|
+
|
|
357
|
+
// Delete identifiers
|
|
358
|
+
await auth.bulkDeleteIdentifiers(userId, ['uuid-2']);
|
|
359
|
+
|
|
360
|
+
// Change primary
|
|
361
|
+
await auth.changePrimaryIdentifier(userId, 'uuid-3');
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
226
366
|
## Configuration
|
|
227
367
|
|
|
228
368
|
### `algorithm`
|
|
@@ -259,8 +399,11 @@ After login, both cookies are set automatically. `protect()` reads the access to
|
|
|
259
399
|
| `POST` | `/refresh` | — | Rotate refresh token |
|
|
260
400
|
| `POST` | `/logout` | — | Invalidate current session |
|
|
261
401
|
| `POST` | `/logout-all` | ✓ | Invalidate all sessions |
|
|
262
|
-
| `GET` | `/me` | ✓ | Return authenticated user |
|
|
263
|
-
| `
|
|
402
|
+
| `GET` | `/me` | ✓ | Return authenticated user with all identifiers |
|
|
403
|
+
| `POST` | `/me/identifiers` | ✓ self | Add identifiers in bulk |
|
|
404
|
+
| `PUT` | `/me/identifiers` | ✓ self | Update identifiers in bulk |
|
|
405
|
+
| `DELETE` | `/me/identifiers` | ✓ self | Delete identifiers in bulk |
|
|
406
|
+
| `PATCH` | `/me/identifiers/primary` | ✓ self | Change primary identifier |
|
|
264
407
|
| `PATCH` | `/me/password` | ✓ self | Change password — revokes all sessions |
|
|
265
408
|
| `POST` | `/users/:userId/roles` | ✓ admin | Assign roles to user |
|
|
266
409
|
| `GET` | `/keys` | — | Public key in JWKS format (RS256 only) |
|
|
@@ -270,16 +413,6 @@ All responses use the envelope:
|
|
|
270
413
|
{ "error": false, "statusCode": 200, "message": "...", "data": { ... } }
|
|
271
414
|
```
|
|
272
415
|
|
|
273
|
-
### Change Identifier
|
|
274
|
-
|
|
275
|
-
```
|
|
276
|
-
PATCH /me/identifier
|
|
277
|
-
Authorization: Bearer <token>
|
|
278
|
-
Content-Type: application/json
|
|
279
|
-
|
|
280
|
-
{ "identifier": "new@example.com" }
|
|
281
|
-
```
|
|
282
|
-
|
|
283
416
|
### Change Password
|
|
284
417
|
|
|
285
418
|
```
|
|
@@ -302,6 +435,7 @@ Verifies the JWT and sets `req.user`. In server mode, also performs silent token
|
|
|
302
435
|
|
|
303
436
|
```typescript
|
|
304
437
|
app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
|
|
438
|
+
// req.user: { id, identifier, identifierType, roles, identifiers? }
|
|
305
439
|
```
|
|
306
440
|
|
|
307
441
|
Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
|
|
@@ -348,7 +482,7 @@ Available on `ServerAuthClient` only:
|
|
|
348
482
|
const auth = createAuth({ mode: 'server', ... });
|
|
349
483
|
|
|
350
484
|
// Sign
|
|
351
|
-
const accessToken = auth.signAccessToken({ id, identifier, roles });
|
|
485
|
+
const accessToken = auth.signAccessToken({ id, identifier, identifierType, roles });
|
|
352
486
|
const refreshToken = auth.signRefreshToken(sessionId);
|
|
353
487
|
|
|
354
488
|
// Verify
|
|
@@ -376,8 +510,10 @@ app.use(auth.errorHandler()); // must be last
|
|
|
376
510
|
| Code | HTTP | Meaning |
|
|
377
511
|
|---|---|---|
|
|
378
512
|
| `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
|
|
379
|
-
| `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
|
|
380
513
|
| `USER_NOT_FOUND` | 404 | User does not exist |
|
|
514
|
+
| `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
|
|
515
|
+
| `IDENTIFIER_NOT_FOUND` | 404 | Referenced identifier ID does not exist or belongs to another user |
|
|
516
|
+
| `IDENTIFIER_ALREADY_EXISTS` | 409 | Identifier value already taken by another user |
|
|
381
517
|
| `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
|
|
382
518
|
| `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
|
|
383
519
|
| `UNAUTHORIZED` | 401 | No valid token or session not found |
|
|
@@ -455,6 +591,18 @@ app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
|
|
|
455
591
|
|
|
456
592
|
## Migration Guide
|
|
457
593
|
|
|
594
|
+
### 4.0.0 Breaking Changes
|
|
595
|
+
|
|
596
|
+
| What changed | Action required |
|
|
597
|
+
|---|---|
|
|
598
|
+
| `sentri_users` no longer has an `identifier` column | Drop and recreate tables — identities now live in `sentri_identifiers` |
|
|
599
|
+
| New `sentri_identifiers` table | Created automatically by `auth.migrate()` |
|
|
600
|
+
| `RegisterInput.identifier` → `RegisterInput.identifiers` (array) | Update register calls to pass `identifiers: [{ type, value }]` |
|
|
601
|
+
| `AuthUser` now includes `identifierType` | Update any code reading `req.user` to expect this new field |
|
|
602
|
+
| `PATCH /me/identifier` removed | Use `PUT /me/identifiers` for updates, `PATCH /me/identifiers/primary` for primary change |
|
|
603
|
+
| `changeIdentifier()` removed from `ServerAuthClient` | Use `bulkUpdateIdentifiers()` or `changePrimaryIdentifier()` |
|
|
604
|
+
| New error codes: `IDENTIFIER_NOT_FOUND`, `IDENTIFIER_ALREADY_EXISTS` | Handle these in your error handlers as needed |
|
|
605
|
+
|
|
458
606
|
### 3.0.0 Breaking Changes
|
|
459
607
|
|
|
460
608
|
| What changed | Action required |
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
|
|
|
7
7
|
* - `INVALID_CREDENTIALS` — identifier or password did not match (intentionally vague to prevent user enumeration)
|
|
8
8
|
* - `USER_NOT_FOUND` — an operation required a user that does not exist
|
|
9
9
|
* - `USER_ALREADY_EXISTS` — registration was attempted with an identifier already in the database
|
|
10
|
+
* - `IDENTIFIER_NOT_FOUND` — a referenced identifier ID does not exist or does not belong to the user
|
|
11
|
+
* - `IDENTIFIER_ALREADY_EXISTS` — the identifier value is already taken by another user
|
|
10
12
|
* - `TOKEN_EXPIRED` — the JWT was valid but its `exp` claim is in the past
|
|
11
13
|
* - `TOKEN_INVALID` — the JWT could not be verified (bad signature, malformed, wrong type)
|
|
12
14
|
* - `FORBIDDEN` — the user is authenticated but lacks the required role
|
|
@@ -18,7 +20,7 @@ import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
|
|
|
18
20
|
* When you extend {@link SentriError} for your own error types you can use any
|
|
19
21
|
* string as `code` — it does not need to be one of these built-in values.
|
|
20
22
|
*/
|
|
21
|
-
type SentriErrorCode = 'INVALID_CREDENTIALS' | 'USER_NOT_FOUND' | 'USER_ALREADY_EXISTS' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'INVALID_ROLE' | 'VALIDATION_ERROR' | 'CONFIGURATION_ERROR';
|
|
23
|
+
type SentriErrorCode = 'INVALID_CREDENTIALS' | 'USER_NOT_FOUND' | 'USER_ALREADY_EXISTS' | 'IDENTIFIER_NOT_FOUND' | 'IDENTIFIER_ALREADY_EXISTS' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'INVALID_ROLE' | 'VALIDATION_ERROR' | 'CONFIGURATION_ERROR';
|
|
22
24
|
/**
|
|
23
25
|
* Default HTTP status codes for built-in error codes.
|
|
24
26
|
* Custom codes not in this map default to 500.
|
|
@@ -97,10 +99,34 @@ interface ApiResponse<T = null> {
|
|
|
97
99
|
message: string;
|
|
98
100
|
data: T | null;
|
|
99
101
|
}
|
|
102
|
+
/** A single identifier entry belonging to a user. */
|
|
103
|
+
interface IdentifierRecord {
|
|
104
|
+
id: string;
|
|
105
|
+
type: string;
|
|
106
|
+
value: string;
|
|
107
|
+
isPrimary: boolean;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The authenticated user shape. `identifier` and `identifierType` always
|
|
111
|
+
* reflect the primary identifier. `identifiers` is populated only when the
|
|
112
|
+
* full user object is fetched (e.g. GET /me) and is absent from JWT payloads.
|
|
113
|
+
*/
|
|
100
114
|
interface AuthUser<TRole extends string = string> {
|
|
101
115
|
id: string;
|
|
116
|
+
/** Primary identifier value — embedded in the JWT payload. */
|
|
102
117
|
identifier: string;
|
|
118
|
+
/** Type of the primary identifier (e.g. 'email', 'username'). */
|
|
119
|
+
identifierType: string;
|
|
103
120
|
roles: TRole[];
|
|
121
|
+
/** All identifiers for this user. Only present in full user responses, not in JWT. */
|
|
122
|
+
identifiers?: IdentifierRecord[];
|
|
123
|
+
}
|
|
124
|
+
/** Input for a single identifier entry. */
|
|
125
|
+
interface IdentifierInput {
|
|
126
|
+
/** Arbitrary label such as 'email', 'username', or 'phone'. */
|
|
127
|
+
type: string;
|
|
128
|
+
/** The globally unique identifier value. */
|
|
129
|
+
value: string;
|
|
104
130
|
}
|
|
105
131
|
type RegisterResult<TRole extends string = string> = {
|
|
106
132
|
success: true;
|
|
@@ -132,9 +158,16 @@ type GetUserResult<TRole extends string = string> = {
|
|
|
132
158
|
success: false;
|
|
133
159
|
error: SentriError;
|
|
134
160
|
};
|
|
135
|
-
type
|
|
161
|
+
type BulkIdentifiersResult = {
|
|
136
162
|
success: true;
|
|
137
|
-
|
|
163
|
+
identifiers: IdentifierRecord[];
|
|
164
|
+
} | {
|
|
165
|
+
success: false;
|
|
166
|
+
error: SentriError;
|
|
167
|
+
};
|
|
168
|
+
type ChangePrimaryResult = {
|
|
169
|
+
success: true;
|
|
170
|
+
identifiers: IdentifierRecord[];
|
|
138
171
|
} | {
|
|
139
172
|
success: false;
|
|
140
173
|
error: SentriError;
|
|
@@ -155,11 +188,17 @@ type RefreshResult<TRole extends string = string> = {
|
|
|
155
188
|
error: SentriError;
|
|
156
189
|
};
|
|
157
190
|
interface RegisterInput<TRole extends string = string> {
|
|
158
|
-
|
|
191
|
+
/**
|
|
192
|
+
* One or more identifiers for the new user. The first entry becomes the
|
|
193
|
+
* primary identifier (embedded in the JWT payload). All values must be
|
|
194
|
+
* globally unique. At least one identifier is required.
|
|
195
|
+
*/
|
|
196
|
+
identifiers: IdentifierInput[];
|
|
159
197
|
password: string;
|
|
160
198
|
roles?: TRole[];
|
|
161
199
|
}
|
|
162
200
|
interface LoginInput {
|
|
201
|
+
/** Any of the user's identifier values — Sentri searches all types. */
|
|
163
202
|
identifier: string;
|
|
164
203
|
password: string;
|
|
165
204
|
}
|
|
@@ -188,7 +227,14 @@ interface RouterHandlers {
|
|
|
188
227
|
logout?: (refreshToken: string | undefined) => Promise<void>;
|
|
189
228
|
logoutAll?: (userId: string) => Promise<void>;
|
|
190
229
|
assignRoles?: (userId: string, roles: string[]) => Promise<AssignRolesResult>;
|
|
191
|
-
|
|
230
|
+
bulkCreateIdentifiers?: (userId: string, identifiers: IdentifierInput[]) => Promise<BulkIdentifiersResult>;
|
|
231
|
+
bulkUpdateIdentifiers?: (userId: string, updates: Array<{
|
|
232
|
+
id: string;
|
|
233
|
+
type: string;
|
|
234
|
+
value: string;
|
|
235
|
+
}>) => Promise<BulkIdentifiersResult>;
|
|
236
|
+
bulkDeleteIdentifiers?: (userId: string, ids: string[]) => Promise<BulkIdentifiersResult>;
|
|
237
|
+
changePrimaryIdentifier?: (userId: string, identifierId: string) => Promise<ChangePrimaryResult>;
|
|
192
238
|
changePassword?: (userId: string, currentPassword: string, newPassword: string) => Promise<ChangePasswordResult>;
|
|
193
239
|
}
|
|
194
240
|
interface ServerAuthConfig<TRole extends string = string> {
|
|
@@ -406,15 +452,13 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
|
|
|
406
452
|
/** Extract the raw access token from an Express request. */
|
|
407
453
|
getCurrentAccessToken(request: Request): string | undefined;
|
|
408
454
|
/**
|
|
409
|
-
* Pre-built Express Router with auth endpoints
|
|
410
|
-
*
|
|
411
|
-
* POST /logout-all, GET /me, POST /users/:userId/roles,
|
|
412
|
-
* GET /keys (only when algorithm is RS256/RS384/RS512)
|
|
455
|
+
* Pre-built Express Router with auth endpoints.
|
|
456
|
+
* See README for the full endpoint list.
|
|
413
457
|
*/
|
|
414
458
|
router(): Router;
|
|
415
459
|
/**
|
|
416
|
-
* Run database migrations to create sentri_users
|
|
417
|
-
* Safe to call on every startup — uses IF NOT EXISTS.
|
|
460
|
+
* Run database migrations to create sentri_users, sentri_sessions, and
|
|
461
|
+
* sentri_identifiers tables. Safe to call on every startup — uses IF NOT EXISTS.
|
|
418
462
|
*/
|
|
419
463
|
migrate(): Promise<void>;
|
|
420
464
|
/**
|
|
@@ -423,9 +467,9 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
|
|
|
423
467
|
* Uses Redis backend when `redisUrl` is set in server config; otherwise in-memory.
|
|
424
468
|
*/
|
|
425
469
|
idempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
|
|
426
|
-
/** Register a new user. */
|
|
470
|
+
/** Register a new user with one or more identifiers. */
|
|
427
471
|
register(input: RegisterInput<TRole>): Promise<RegisterResult<TRole>>;
|
|
428
|
-
/** Authenticate a user, returns access + refresh tokens. */
|
|
472
|
+
/** Authenticate a user by any of their identifier values, returns access + refresh tokens. */
|
|
429
473
|
login(input: LoginInput): Promise<AuthResult<TRole>>;
|
|
430
474
|
/** Rotate a refresh token, returns new token pair. */
|
|
431
475
|
refresh(refreshToken: string): Promise<RefreshResult<TRole>>;
|
|
@@ -433,14 +477,24 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
|
|
|
433
477
|
logout(refreshToken: string): Promise<void>;
|
|
434
478
|
/** Invalidate all sessions for a user. */
|
|
435
479
|
logoutAll(userId: string): Promise<void>;
|
|
436
|
-
/** Fetch a user by ID. Returns `success: false` if not found. */
|
|
480
|
+
/** Fetch a user by ID, including all their identifiers. Returns `success: false` if not found. */
|
|
437
481
|
getUser(userId: string): Promise<GetUserResult<TRole>>;
|
|
438
|
-
/** Change a user's identifier (username/email). */
|
|
439
|
-
changeIdentifier(userId: string, newIdentifier: string): Promise<ChangeIdentifierResult<TRole>>;
|
|
440
482
|
/** Change a user's password and revoke all their sessions. */
|
|
441
483
|
changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
|
|
442
484
|
/** Add roles to a user (existing roles are preserved). */
|
|
443
485
|
assignRoles(userId: string, roles: TRole[]): Promise<AssignRolesResult<TRole>>;
|
|
486
|
+
/** Add new identifiers to a user in bulk. Returns the full updated identifier list. */
|
|
487
|
+
bulkCreateIdentifiers(userId: string, identifiers: IdentifierInput[]): Promise<BulkIdentifiersResult>;
|
|
488
|
+
/** Update type and value for multiple identifiers in bulk. Returns the full updated identifier list. */
|
|
489
|
+
bulkUpdateIdentifiers(userId: string, updates: Array<{
|
|
490
|
+
id: string;
|
|
491
|
+
type: string;
|
|
492
|
+
value: string;
|
|
493
|
+
}>): Promise<BulkIdentifiersResult>;
|
|
494
|
+
/** Delete multiple identifiers by ID. At least one identifier must remain. Returns the remaining list. */
|
|
495
|
+
bulkDeleteIdentifiers(userId: string, ids: string[]): Promise<BulkIdentifiersResult>;
|
|
496
|
+
/** Change which identifier is marked as primary (embedded in JWT on next login/refresh). */
|
|
497
|
+
changePrimaryIdentifier(userId: string, identifierId: string): Promise<ChangePrimaryResult>;
|
|
444
498
|
}
|
|
445
499
|
interface ClientAuthClient<TRole extends string = string> extends AuthClient<TRole> {
|
|
446
500
|
}
|
|
@@ -549,4 +603,4 @@ declare global {
|
|
|
549
603
|
}
|
|
550
604
|
}
|
|
551
605
|
|
|
552
|
-
export { type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type
|
|
606
|
+
export { type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type BulkIdentifiersResult, type ChangePasswordResult, type ChangePrimaryResult, type ClientAuthClient, type ClientAuthConfig, type CookieConfig, type CreateServerOptions, type ErrorHandlerOptions, type GetUserResult, type IdempotencyOptions, type IdentifierInput, type IdentifierRecord, type LoginInput, type PermitCheck, type PermitOptions, type PostgresConfig, type RefreshResult, type RegisterInput, type RegisterResult, type RouterHandlers, SENTRI_ERROR_STATUS, SentriError, type SentriErrorCode, type ServerAuthClient, type ServerAuthConfig, createAuth, createAuthServer, createErrorHandler, createIdempotencyMiddleware, getCurrentAccessToken, register };
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import Re from'bcrypt';import H from'jsonwebtoken';import {generateKeyPairSync,randomUUID,createPublicKey,createPrivateKey}from'crypto';import {Kysely,sql,PostgresDialect}from'kysely';import {Router}from'express';import {Redis}from'ioredis';import {Pool}from'pg';var pe=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),i=class extends Error{code;statusCode;constructor(r,s,t){super(s),this.name="SentriError",this.code=r,this.statusCode=t??pe[r]??500;}};async function D(e,r=12){return Re.hash(e,r)}async function K(e,r){return Re.compare(e,r)}var ye=new Map,we=new Map,nr=3600*1e3;function Ce(e){let r=ye.get(e);if(!r){let s=createPrivateKey(e),t=createPublicKey(s),n=t.export({format:"jwk"}),o=Buffer.from(e).slice(0,8).toString("base64url"),c={...n,use:"sig",kid:o};r={kid:o,publicKey:t,jwk:c},ye.set(e,r);}return r}function ke(e){let{jwk:r}=Ce(e);return {keys:[r]}}function Ie(e){return Ce(e).publicKey}async function Se(e){let r=Date.now(),s=we.get(e);if(s&&r-s.fetchedAt<nr)return s.publicKey;let t=await fetch(e);if(!t.ok)throw new i("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${t.status}`);let n=await t.json();if(!n.keys||n.keys.length===0)throw new i("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let o=n.keys[0],c=createPublicKey({key:o,format:"jwk"});return we.set(e,{publicKey:c,fetchedAt:r}),c}var Te=new WeakMap,ve=32,Ee=10,xe=31;function _e(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new i("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new i("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<ve)throw new i("CONFIGURATION_ERROR",`secret must be at least ${ve} characters for HMAC algorithms`);let t=e.saltRounds??12;if(!Number.isInteger(t)||t<Ee||t>xe)throw new i("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Ee} and ${xe}`);if(!e.validRoles||e.validRoles.length===0)throw new i("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new i("CONFIGURATION_ERROR","dialect is required in server mode")}function p(e){let r=Te.get(e);if(r)return r;let s={algorithm:e.algorithm??"HS256",accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return Te.set(e,s),s}var or=/^(\d+)([smhdw])$/,ir={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},be=new Map;function x(e){if(typeof e=="number")return e*1e3;let r=be.get(e);if(r!==void 0)return r;let s=or.exec(e);if(!s?.[1]||!s?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let t=ir[s[2]];if(t===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let n=parseInt(s[1],10)*t;return be.set(e,n),n}var Pe=new Map,Oe=new Map,Ne=new Map;function Ue(e){return e.startsWith("RS")||e.startsWith("PS")}function De(e){let r=Pe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Pe.set(e,r)),r}function cr(e){let r=Oe.get(e);return r||(r=createPrivateKey(e),Oe.set(e,r)),r}function Ke(e){let r=p(e);if(Ue(r.algorithm)){let n=cr(e.secret);return {accessKey:n,refreshKey:n}}let{access:s,refresh:t}=De(e.secret);return {accessKey:s,refreshKey:t}}function He(e,r){let s=p(e);if(Ue(s.algorithm))return Ie(e.secret);let{access:t,refresh:n}=De(e.secret);return r==="access"?t:n}function je(e,r,s,t){let n=`${s}:${t}`,o=Ne.get(n);return o||(o={expiresIn:s,algorithm:t},Ne.set(n,o)),H.sign(e,r,o)}function Le(e,r,s){try{let t=H.verify(e,r,{algorithms:[s]});if(typeof t=="string"||t===null)throw new i("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof i?t:t instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function j(e,r){let s=p(r),{accessKey:t}=Ke(r);return je(e,t,s.accessExpiresIn,s.algorithm)}function L(e,r){let s=p(r),{refreshKey:t}=Ke(r);return je({sessionId:e},t,s.refreshExpiresIn,s.algorithm)}function z(e,r){let s=p(r),t=He(r,"access");return Le(e,t,s.algorithm)}function F(e,r){let s=p(r),t=He(r,"refresh");return Le(e,t,s.algorithm)}function Fe(e,r){try{let s=H.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof s=="string"||s===null)throw new i("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof i?s:s instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function E(e){return p(e).cookieName}function b(e,r){if(!e)return;let s=`${r}=`,t=0;for(;t<e.length;){for(;t<e.length&&e[t]===" ";)t++;let n=e.indexOf(";",t),o=n===-1?e.length:n;if(e.startsWith(s,t))return e.slice(t+s.length,o);t=o+1;}}function q(e,r,s){let t=s.cookie??{},n=p(s),o=x(n.refreshExpiresIn);e.cookie(E(s),r,{httpOnly:t.httpOnly??true,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function W(e,r){let s=r.cookie??{};e.clearCookie(E(r),{path:s.path??"/"});}function ce(e){return p(e).accessCookieName}function M(e,r,s){if(!s.accessCookie)return;let t=s.accessCookie,n=p(s),o=x(n.accessExpiresIn);e.cookie(ce(s),r,{httpOnly:false,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function ue(e,r){if(!r.accessCookie)return;let s=r.accessCookie;e.clearCookie(ce(r),{path:s.path??"/"});}function $(e,r){let s=e.headers.authorization;return s?.startsWith("Bearer ")?s.slice(7):b(e.headers.cookie,ce(r))}var qe=new Map;function k(e){let r=qe.get(e);return r||(r=new Kysely({dialect:e}),qe.set(e,r)),r}function de(e){try{return JSON.parse(e)}catch{return []}}function $e(e){return JSON.stringify(e)}async function Z(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("identifier","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function J(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("id","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function Je(e,r){let s=randomUUID();return await e.insertInto("sentri_users").values({id:s,identifier:r.identifier,password_hash:r.passwordHash,roles:$e(r.roles)}).execute(),{id:s}}async function Ge(e,r,s){await e.updateTable("sentri_users").set({identifier:s}).where("id","=",r).execute();}async function Xe(e,r,s){await e.updateTable("sentri_users").set({password_hash:s}).where("id","=",r).execute();}async function Ve(e,r,s){await e.updateTable("sentri_users").set({roles:$e(s)}).where("id","=",r).execute();}async function le(e,r){let s=randomUUID();return await e.insertInto("sentri_sessions").values({id:s,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:s}}async function Be(e,r){let s=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").select(["s.id as session_id","s.user_id","s.expires_at","s.created_at as session_created_at","u.id as user_id_col","u.identifier","u.password_hash","u.roles"]).where("s.id","=",r).executeTakeFirst();return s?{id:s.session_id,userId:s.user_id,expiresAt:new Date(s.expires_at),createdAt:new Date(s.session_created_at),user:{id:s.user_id_col,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}}:null}async function Y(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function fe(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function G(e,r){let s=p(r),t=k(r.dialect),n=e.roles??[],o=n.filter(w=>!s.validRolesSet.has(w));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=e.identifier.trim();if(await Z(t,c))return {success:false,error:new i("USER_ALREADY_EXISTS","User already exists")};let g=await D(e.password,s.saltRounds);return {success:true,user:{id:(await Je(t,{identifier:c,passwordHash:g,roles:n})).id,identifier:c,roles:n}}}async function Q(e,r){let s=p(r),t=k(r.dialect),n=await Z(t,e.identifier.trim());if(!n)return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};if(!await K(e.password,n.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:n.id,expiresAt:c}),g={id:n.id,identifier:n.identifier,roles:n.roles},m=j({id:n.id,identifier:n.identifier,roles:n.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function _(e,r){let s=p(r),t=k(r.dialect),n;try{({sessionId:n}=F(e,r));}catch(v){return v instanceof i?{success:false,error:v}:{success:false,error:new i("TOKEN_INVALID","Invalid refresh token")}}let o=await Be(t,n);if(!o)return {success:false,error:new i("UNAUTHORIZED","Session not found or revoked")};if(o.expiresAt.getTime()<Date.now())return await Y(t,n),{success:false,error:new i("TOKEN_EXPIRED","Session has expired")};await Y(t,n);let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:o.userId,expiresAt:c}),g={id:o.user.id,identifier:o.user.identifier,roles:o.user.roles},m=j({id:g.id,identifier:g.identifier,roles:g.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function ee(e,r){let s=k(r.dialect),t;try{({sessionId:t}=F(e,r));}catch{return}await Y(s,t);}async function re(e,r){let s=k(r.dialect);await fe(s,e);}async function ze(e,r){let s=k(r.dialect),t=await J(s,e);return t?{success:true,user:{id:t.id,identifier:t.identifier,roles:t.roles}}:{success:false,error:new i("USER_NOT_FOUND","User not found")}}async function te(e,r,s){let t=k(s.dialect),n=await J(t,e);if(!n)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let o=r.trim();return await Z(t,o)?{success:false,error:new i("USER_ALREADY_EXISTS","Identifier already taken")}:(await Ge(t,e,o),{success:true,user:{id:n.id,identifier:o,roles:n.roles}})}async function se(e,r,s,t){let n=p(t),o=k(t.dialect),c=await J(o,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};if(!await K(r,c.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let g=await D(s,n.saltRounds);return await Xe(o,e,g),await fe(o,e),{success:true}}async function ne(e,r,s){let t=p(s),n=k(s.dialect),o=r.filter(m=>!t.validRolesSet.has(m));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=await J(n,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let d=new Set(c.roles);for(let m of r)d.add(m);let g=Array.from(d);return await Ve(n,e,g),{success:true,user:{id:c.id,identifier:c.identifier,roles:g}}}function S(e){return e.mode==="client"?dr(e.keyUri):lr(e)}function dr(e){return async(r,s,t)=>{let n=r.headers.authorization,o=n?.startsWith("Bearer ")?n.slice(7):void 0;if(!o)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let c=await Se(e),d=Fe(o,c);r.user={id:d.id,identifier:d.identifier,roles:d.roles},t();}catch(c){t(c);}}}function lr(e){return async(r,s,t)=>{let n=$(r,e);if(!n)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let o=z(n,e);if(e.isTokenRevoked&&await e.isTokenRevoked(o.sessionId))return t(new i("UNAUTHORIZED","Token has been revoked"));r.user={id:o.id,identifier:o.identifier,roles:o.roles},t();}catch(o){if(o instanceof i&&o.code==="TOKEN_EXPIRED"){let c=b(r.headers.cookie,E(e));if(!c)return t(new i("UNAUTHORIZED","Token expired. Please login again."));try{let d=await _(c,e);if(!d.success)return t(new i("UNAUTHORIZED","Session expired. Please login again."));q(s,d.refreshToken,e),M(s,d.accessToken,e),s.setHeader("X-New-Access-Token",d.accessToken),r.user=d.user,t();}catch{t(new i("UNAUTHORIZED","Session expired. Please login again."));}}else t(o);}}}function X(...e){let r=`Requires one of roles: ${e.join(", ")}`;return (s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));let o=s.user.roles;if(!e.some(c=>o.includes(c)))return n(new i("FORBIDDEN",r));n();}}var fr=new i("FORBIDDEN","You do not have permission to perform this action");function V(e){let r=typeof e=="function"?{check:e}:e;return async(s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));if(r.roles&&r.roles.length>0){let o=s.user.roles;if(r.roles.some(c=>o.includes(c)))return n()}try{let o=r.check(s);(o instanceof Promise?await o:o)?n():n(fr);}catch(o){n(o);}}}function P(e){return (r,s,t,n)=>{if(r instanceof i){t.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),t.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var oe=8,O=72,N=255;function y(e){return new i("VALIDATION_ERROR",e)}function T(e,r,s,t){e.status(r).json({error:false,statusCode:r,message:s,data:t});}function U(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function B(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new i("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function hr(e,r){if(!r.apiKey)return;let s=e.headers["x-api-key"];if(typeof s!="string"||s!==r.apiKey)throw new i("UNAUTHORIZED","Invalid or missing API key")}function ge(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function mr(e){return e.startsWith("RS")||e.startsWith("PS")}function We(e){let r=Router(),s=e,t=p(s),n=e.router?.register??(a=>G(a,s)),o=e.router?.login??(a=>Q(a,s)),c=e.router?.refresh??(a=>_(a,s)),d=e.router?.logout??(a=>a!==void 0?ee(a,s):Promise.resolve()),g=e.router?.logoutAll??(a=>re(a,s)),m=e.router?.assignRoles??((a,u)=>ne(a,u,s)),w=e.router?.changeIdentifier??((a,u)=>te(a,u,s)),v=e.router?.changePassword??((a,u,R)=>se(a,u,R,s));mr(t.algorithm)&&r.get("/keys",(a,u)=>{u.setHeader("Cache-Control","public, max-age=3600"),u.json(ke(e.secret));}),r.post("/register",async(a,u,R)=>{try{hr(a,e);let l=B(a.body),{identifier:f,password:h,roles:A}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length<oe)throw y(`password is required and must be at least ${oe} characters`);if(h.length>O)throw y(`password must not exceed ${O} characters`);if(A!==void 0&&!Array.isArray(A))throw y("roles must be an array of strings when provided");if(Array.isArray(A)&&!A.every(tr=>typeof tr=="string"))throw y("each role must be a string");let C=Array.isArray(A)?A:void 0,ie=C!==void 0?{identifier:f.trim(),password:h,roles:C}:{identifier:f.trim(),password:h},ae=await n(ie);if(!ae.success){U(u,ae.error);return}T(u,201,"User registered successfully",{user:ae.user});}catch(l){R(l);}}),r.post("/login",async(a,u,R)=>{try{let l=B(a.body),{identifier:f,password:h}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length===0)throw y("password is required");if(h.length>O)throw y(`password must not exceed ${O} characters`);let A=f.trim(),C=await o({identifier:A,password:h});if(!C.success){ge(()=>e.hooks?.onFailedLogin?.(A,C.error)),U(u,C.error);return}ge(()=>e.hooks?.onLogin?.(C.user)),q(u,C.refreshToken,e),M(u,C.accessToken,e),T(u,200,"Login successful",{accessToken:C.accessToken,user:C.user});}catch(l){R(l);}}),r.post("/refresh",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));if(!l)throw new i("UNAUTHORIZED","Refresh token cookie is missing");let f=await c(l);if(!f.success){W(u,e),U(u,f.error);return}q(u,f.refreshToken,e),M(u,f.accessToken,e),T(u,200,"Token refreshed",{accessToken:f.accessToken});}catch(l){R(l);}}),r.post("/logout",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));await d(l),W(u,e),ue(u,e),T(u,200,"Logged out",null);}catch(l){R(l);}}),r.post("/logout-all",S(e),async(a,u,R)=>{try{let l=a.user.id;await g(l),ge(()=>e.hooks?.onLogout?.(l)),W(u,e),ue(u,e),T(u,200,"All sessions revoked",null);}catch(l){R(l);}}),r.get("/me",S(e),(a,u)=>{T(u,200,"OK",a.user);});let I=V(a=>!!a.user);return r.patch("/me/identifier",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{newIdentifier:f}=l;if(typeof f!="string"||f.trim().length===0)throw y("newIdentifier is required and must be a non-empty string");if(f.length>N)throw y(`newIdentifier must not exceed ${N} characters`);let h=await w(a.user.id,f);if(!h.success){U(u,h.error);return}T(u,200,"Identifier updated successfully",{user:h.user});}catch(l){R(l);}}),r.patch("/me/password",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{currentPassword:f,newPassword:h}=l;if(typeof f!="string"||f.length===0)throw y("currentPassword is required");if(typeof h!="string"||h.length<oe)throw y(`newPassword must be at least ${oe} characters`);if(h.length>O)throw y(`newPassword must not exceed ${O} characters`);if(f===h)throw y("newPassword must be different from currentPassword");let A=await v(a.user.id,f,h);if(!A.success){U(u,A.error);return}T(u,200,"Password updated successfully. All sessions have been revoked.",null);}catch(l){R(l);}}),r.post("/users/:userId/roles",S(e),X("admin"),async(a,u,R)=>{try{let l=B(a.body),{roles:f}=l,h=a.params.userId,A=typeof h=="string"?h:void 0;if(!A)throw y("userId is required");if(!Array.isArray(f)||f.length===0)throw y("roles must be a non-empty array of strings");if(!f.every(ie=>typeof ie=="string"))throw y("each role must be a string");let C=await m(A,f);if(!C.success){U(u,C.error);return}T(u,200,"Roles assigned successfully",{user:C.user});}catch(l){R(l);}}),r.use(P()),r}var Ze=new Map;function Ye(e){let r=Ze.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),Ze.set(e,r)),r}function he(e){let r=e?.ttl??3e5,s=(e?.header??"X-Idempotency-Key").toLowerCase(),t=new Set((e?.methods??["POST","PUT","PATCH"]).map(o=>o.toUpperCase())),n=e?.redisUrl;return n?Rr(n,r,s,t):yr(r,s,t,e?.maxSize??1e4)}function Rr(e,r,s,t){let n=Ye(e),o="sentri:idempotency:";return async(c,d,g)=>{let m=c.headers[s];if(!m||typeof m!="string"||!t.has(c.method))return g();c.requestId=m,d.setHeader("X-Request-Id",m);let w=await n.get(`${o}${m}`);if(w){let I=JSON.parse(w);return d.setHeader("X-Idempotent-Replayed","true"),d.status(I.statusCode).json(I.body)}let v=d.json.bind(d);d.json=function(a){if(d.statusCode>=200&&d.statusCode<300){let u={statusCode:d.statusCode,body:a,expiresAt:Date.now()+r};n.set(`${o}${m}`,JSON.stringify(u),"PX",r).catch(()=>{});}return v(a)},g();}}function yr(e,r,s,t){let n=Math.max(e,5e3),o=new Map,c=setInterval(()=>{let d=Date.now();for(let[g,m]of o)m.expiresAt<=d&&o.delete(g);},n);return typeof c=="object"&&c!==null&&"unref"in c&&c.unref(),(d,g,m)=>{let w=d.headers[r];if(!w||typeof w!="string"||!s.has(d.method))return m();d.requestId=w,g.setHeader("X-Request-Id",w);let v=Date.now(),I=o.get(w);if(I&&I.expiresAt>v)return g.setHeader("X-Idempotent-Replayed","true"),g.status(I.statusCode).json(I.body);let a=g.json.bind(g);g.json=function(R){if(g.statusCode>=200&&g.statusCode<300){if(o.size>=t){let l=o.keys().next().value;l!==void 0&&o.delete(l);}o.set(w,{statusCode:g.statusCode,body:R,expiresAt:Date.now()+e});}return a(R)},m();}}async function er(e){await e.schema.createTable("sentri_users").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("identifier","varchar(255)",r=>r.notNull().unique()).addColumn("password_hash","varchar(255)",r=>r.notNull()).addColumn("roles","text",r=>r.notNull().defaultTo("[]")).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_sessions").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("expires_at","timestamp",r=>r.notNull()).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute();}function me(e){if(_e(e),e.mode==="client")return {protect:()=>S(e),authorize:(...t)=>X(...t),permit:t=>V(t),errorHandler:t=>P(t)};let r=e,s=p(r);return {protect:()=>S(r),authorize:(...t)=>X(...t),permit:t=>V(t),hashPassword:t=>D(t,s.saltRounds),verifyPassword:(t,n)=>K(t,n),signAccessToken:t=>j(t,r),signRefreshToken:t=>L(t,r),verifyAccessToken:t=>z(t,r),verifyRefreshToken:t=>F(t,r),getCurrentAccessToken:t=>$(t,r),router:()=>We(r),migrate:()=>er(k(r.dialect)),errorHandler:t=>P(t),idempotencyMiddleware:t=>he({...t,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:t=>G(t,r),login:t=>Q(t,r),refresh:t=>_(t,r),logout:t=>ee(t,r),logoutAll:t=>re(t,r),getUser:t=>ze(t,r),changeIdentifier:(t,n)=>te(t,n,r),changePassword:(t,n,o)=>se(t,n,o,r),assignRoles:(t,n)=>ne(t,n,r)}}function rr(e){return new PostgresDialect({pool:new Pool(e)})}function kr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),s={mode:"server",dialect:rr(e.db),secret:r,algorithm:"RS256",validRoles:e.validRoles,...e.accessExpiresIn!==void 0&&{accessExpiresIn:e.accessExpiresIn},...e.refreshExpiresIn!==void 0&&{refreshExpiresIn:e.refreshExpiresIn},...e.saltRounds!==void 0&&{saltRounds:e.saltRounds},...e.apiKey!==void 0&&{apiKey:e.apiKey},...e.cookie!==void 0&&{cookie:e.cookie},...e.accessCookie!==void 0&&{accessCookie:e.accessCookie},...e.hooks!==void 0&&{hooks:e.hooks},...e.router!==void 0&&{router:e.router},...e.isTokenRevoked!==void 0&&{isTokenRevoked:e.isTokenRevoked},...e.redisUrl!==void 0&&{redisUrl:e.redisUrl}};return me(s)}export{pe as SENTRI_ERROR_STATUS,i as SentriError,me as createAuth,kr as createAuthServer,P as createErrorHandler,he as createIdempotencyMiddleware,$ as getCurrentAccessToken,G as register};
|
|
1
|
+
import xe from'bcrypt';import B from'jsonwebtoken';import {randomUUID,generateKeyPairSync,createPublicKey,createPrivateKey}from'crypto';import {Kysely,sql,PostgresDialect}from'kysely';import {Router}from'express';import {Redis}from'ioredis';import {Pool}from'pg';var be=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,IDENTIFIER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,IDENTIFIER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),o=class extends Error{code;statusCode;constructor(r,s,t){super(s),this.name="SentriError",this.code=r,this.statusCode=t??be[r]??500;}};async function M(e,r=12){return xe.hash(e,r)}async function V(e,r){return xe.compare(e,r)}var Pe=new Map,Ne=new Map,Ar=3600*1e3;function Oe(e){let r=Pe.get(e);if(!r){let s=createPrivateKey(e),t=createPublicKey(s),i=t.export({format:"jwk"}),n=Buffer.from(e).slice(0,8).toString("base64url"),d={...i,use:"sig",kid:n};r={kid:n,publicKey:t,jwk:d},Pe.set(e,r);}return r}function Ue(e){let{jwk:r}=Oe(e);return {keys:[r]}}function Ke(e){return Oe(e).publicKey}async function He(e){let r=Date.now(),s=Ne.get(e);if(s&&r-s.fetchedAt<Ar)return s.publicKey;let t=await fetch(e);if(!t.ok)throw new o("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${t.status}`);let i=await t.json();if(!i.keys||i.keys.length===0)throw new o("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let n=i.keys[0],d=createPublicKey({key:n,format:"jwk"});return Ne.set(e,{publicKey:d,fetchedAt:r}),d}var Fe=new WeakMap,je=32,Le=10,qe=31;function Me(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new o("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new o("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<je)throw new o("CONFIGURATION_ERROR",`secret must be at least ${je} characters for HMAC algorithms`);let t=e.saltRounds??12;if(!Number.isInteger(t)||t<Le||t>qe)throw new o("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Le} and ${qe}`);if(!e.validRoles||e.validRoles.length===0)throw new o("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new o("CONFIGURATION_ERROR","dialect is required in server mode")}function A(e){let r=Fe.get(e);if(r)return r;let s={algorithm:e.algorithm??"HS256",accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return Fe.set(e,s),s}var vr=/^(\d+)([smhdw])$/,Tr={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},$e=new Map;function O(e){if(typeof e=="number")return e*1e3;let r=$e.get(e);if(r!==void 0)return r;let s=vr.exec(e);if(!s?.[1]||!s?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let t=Tr[s[2]];if(t===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let i=parseInt(s[1],10)*t;return $e.set(e,i),i}var Ve=new Map,Be=new Map,Je=new Map;function Xe(e){return e.startsWith("RS")||e.startsWith("PS")}function Ge(e){let r=Ve.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Ve.set(e,r)),r}function Cr(e){let r=Be.get(e);return r||(r=createPrivateKey(e),Be.set(e,r)),r}function ze(e){let r=A(e);if(Xe(r.algorithm)){let i=Cr(e.secret);return {accessKey:i,refreshKey:i}}let{access:s,refresh:t}=Ge(e.secret);return {accessKey:s,refreshKey:t}}function We(e,r){let s=A(e);if(Xe(s.algorithm))return Ke(e.secret);let{access:t,refresh:i}=Ge(e.secret);return r==="access"?t:i}function Ze(e,r,s,t){let i=`${s}:${t}`,n=Je.get(i);return n||(n={expiresIn:s,algorithm:t},Je.set(i,n)),B.sign(e,r,n)}function Ye(e,r,s){try{let t=B.verify(e,r,{algorithms:[s]});if(typeof t=="string"||t===null)throw new o("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof o?t:t instanceof B.TokenExpiredError?new o("TOKEN_EXPIRED","Token has expired"):new o("TOKEN_INVALID","Token is invalid or malformed")}}function J(e,r){let s=A(r),{accessKey:t}=ze(r);return Ze(e,t,s.accessExpiresIn,s.algorithm)}function X(e,r){let s=A(r),{refreshKey:t}=ze(r);return Ze({sessionId:e},t,s.refreshExpiresIn,s.algorithm)}function se(e,r){let s=A(r),t=We(r,"access");return Ye(e,t,s.algorithm)}function G(e,r){let s=A(r),t=We(r,"refresh");return Ye(e,t,s.algorithm)}function Qe(e,r){try{let s=B.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof s=="string"||s===null)throw new o("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof o?s:s instanceof B.TokenExpiredError?new o("TOKEN_EXPIRED","Token has expired"):new o("TOKEN_INVALID","Token is invalid or malformed")}}function D(e){return A(e).cookieName}function U(e,r){if(!e)return;let s=`${r}=`,t=0;for(;t<e.length;){for(;t<e.length&&e[t]===" ";)t++;let i=e.indexOf(";",t),n=i===-1?e.length:i;if(e.startsWith(s,t))return e.slice(t+s.length,n);t=n+1;}}function z(e,r,s){let t=s.cookie??{},i=A(s),n=O(i.refreshExpiresIn);e.cookie(D(s),r,{httpOnly:t.httpOnly??true,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:n});}function ie(e,r){let s=r.cookie??{};e.clearCookie(D(r),{path:s.path??"/"});}function we(e){return A(e).accessCookieName}function W(e,r,s){if(!s.accessCookie)return;let t=s.accessCookie,i=A(s),n=O(i.accessExpiresIn);e.cookie(we(s),r,{httpOnly:false,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:n});}function Ie(e,r){if(!r.accessCookie)return;let s=r.accessCookie;e.clearCookie(we(r),{path:s.path??"/"});}function Z(e,r){let s=e.headers.authorization;return s?.startsWith("Bearer ")?s.slice(7):U(e.headers.cookie,we(r))}var er=new Map;function T(e){let r=er.get(e);return r||(r=new Kysely({dialect:e}),er.set(e,r)),r}function Ae(e){try{return JSON.parse(e)}catch{return []}}function Sr(e){return JSON.stringify(e)}function tr(e){return {id:e.id,userId:e.user_id,type:e.type,value:e.value,isPrimary:e.is_primary===1,createdAt:new Date(e.created_at)}}async function Y(e,r){let s=await e.selectFrom("sentri_identifiers as i_login").innerJoin("sentri_users as u","u.id","i_login.user_id").innerJoin("sentri_identifiers as i_primary",t=>t.onRef("i_primary.user_id","=","u.id").on("i_primary.is_primary","=",1)).select(["u.id","u.password_hash","u.roles","i_primary.value as primary_value","i_primary.type as primary_type"]).where("i_login.value","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.primary_value,identifierType:s.primary_type,passwordHash:s.password_hash,roles:Ae(s.roles)}:null}async function ne(e,r){let s=await e.selectFrom("sentri_users as u").innerJoin("sentri_identifiers as i",t=>t.onRef("i.user_id","=","u.id").on("i.is_primary","=",1)).select(["u.id","u.password_hash","u.roles","i.value as primary_value","i.type as primary_type"]).where("u.id","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.primary_value,identifierType:s.primary_type,passwordHash:s.password_hash,roles:Ae(s.roles)}:null}async function sr(e,r,s){let t=s.map(i=>({id:randomUUID(),user_id:r,type:i.type,value:i.value,is_primary:i.isPrimary?1:0}));return await e.insertInto("sentri_identifiers").values(t).execute(),t.map(i=>({id:i.id,userId:i.user_id,type:i.type,value:i.value,isPrimary:i.is_primary===1,createdAt:new Date}))}async function K(e,r){return (await e.selectFrom("sentri_identifiers").selectAll().where("user_id","=",r).orderBy("created_at","asc").execute()).map(tr)}async function oe(e,r,s){let t=await e.selectFrom("sentri_identifiers").selectAll().where("id","=",r).where("user_id","=",s).executeTakeFirst();return t?tr(t):null}async function ir(e,r){let s=await e.selectFrom("sentri_identifiers").select(t=>t.fn.countAll().as("count")).where("user_id","=",r).executeTakeFirst();return Number(s?.count??0)}async function nr(e,r,s,t){await e.updateTable("sentri_identifiers").set({type:t.type,value:t.value}).where("id","=",r).where("user_id","=",s).execute();}async function or(e,r,s){await e.deleteFrom("sentri_identifiers").where("user_id","=",r).where("id","in",s).execute();}async function ar(e,r,s){await e.transaction().execute(async t=>{await t.updateTable("sentri_identifiers").set({is_primary:0}).where("user_id","=",r).execute(),await t.updateTable("sentri_identifiers").set({is_primary:1}).where("id","=",s).where("user_id","=",r).execute();});}async function ur(e,r,s){await e.updateTable("sentri_users").set({password_hash:s}).where("id","=",r).execute();}async function dr(e,r,s){await e.updateTable("sentri_users").set({roles:Sr(s)}).where("id","=",r).execute();}async function ve(e,r){let s=randomUUID();return await e.insertInto("sentri_sessions").values({id:s,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:s}}async function cr(e,r){let s=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").innerJoin("sentri_identifiers as i",t=>t.onRef("i.user_id","=","u.id").on("i.is_primary","=",1)).select(["s.id as session_id","s.user_id","s.expires_at","s.created_at as session_created_at","u.id as user_id_col","u.password_hash","u.roles","i.value as primary_identifier","i.type as primary_identifier_type"]).where("s.id","=",r).executeTakeFirst();return s?{id:s.session_id,userId:s.user_id,expiresAt:new Date(s.expires_at),createdAt:new Date(s.session_created_at),user:{id:s.user_id_col,identifier:s.primary_identifier,identifierType:s.primary_identifier_type,passwordHash:s.password_hash,roles:Ae(s.roles)}}:null}async function ae(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function Te(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function Q(e,r){let s=A(r),t=T(r.dialect),i=e.roles??[],n=i.filter(R=>!s.validRolesSet.has(R));if(n.length>0)return {success:false,error:new o("INVALID_ROLE",`Invalid roles: ${n.join(", ")}`)};if(!e.identifiers||e.identifiers.length===0)return {success:false,error:new o("VALIDATION_ERROR","At least one identifier is required")};let d=e.identifiers.map(R=>({type:R.type.trim(),value:R.value.trim()}));if(new Set(d.map(R=>R.value)).size!==d.length)return {success:false,error:new o("VALIDATION_ERROR","Duplicate identifier values in request")};for(let R of d)if(await Y(t,R.value))return {success:false,error:new o("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${R.value}`)};let l=await M(e.password,s.saltRounds),{userId:g,identifierRows:v}=await t.transaction().execute(async R=>{let x=randomUUID();await R.insertInto("sentri_users").values({id:x,password_hash:l,roles:JSON.stringify(i)}).execute();let E=d.map((u,c)=>({id:randomUUID(),user_id:x,type:u.type,value:u.value,is_primary:c===0?1:0}));return await R.insertInto("sentri_identifiers").values(E).execute(),{userId:x,identifierRows:E}}),S=v.map(R=>({id:R.id,type:R.type,value:R.value,isPrimary:R.is_primary===1})),k=S[0];return {success:true,user:{id:g,identifier:k.value,identifierType:k.type,roles:i,identifiers:S}}}async function ue(e,r){let s=A(r),t=T(r.dialect),i=await Y(t,e.identifier.trim());if(!i)return {success:false,error:new o("INVALID_CREDENTIALS","Invalid credentials")};if(!await V(e.password,i.passwordHash))return {success:false,error:new o("INVALID_CREDENTIALS","Invalid credentials")};let d=new Date(Date.now()+O(s.refreshExpiresIn)),a=await ve(t,{userId:i.id,expiresAt:d}),l={id:i.id,identifier:i.identifier,identifierType:i.identifierType,roles:i.roles},g=J({id:i.id,identifier:i.identifier,identifierType:i.identifierType,roles:i.roles,sessionId:a.id},r),v=X(a.id,r);return {success:true,accessToken:g,refreshToken:v,user:l}}async function H(e,r){let s=A(r),t=T(r.dialect),i;try{({sessionId:i}=G(e,r));}catch(S){return S instanceof o?{success:false,error:S}:{success:false,error:new o("TOKEN_INVALID","Invalid refresh token")}}let n=await cr(t,i);if(!n)return {success:false,error:new o("UNAUTHORIZED","Session not found or revoked")};if(n.expiresAt.getTime()<Date.now())return await ae(t,i),{success:false,error:new o("TOKEN_EXPIRED","Session has expired")};await ae(t,i);let d=new Date(Date.now()+O(s.refreshExpiresIn)),a=await ve(t,{userId:n.userId,expiresAt:d}),l={id:n.user.id,identifier:n.user.identifier,identifierType:n.user.identifierType,roles:n.user.roles},g=J({...l,sessionId:a.id},r),v=X(a.id,r);return {success:true,accessToken:g,refreshToken:v,user:l}}async function de(e,r){let s=T(r.dialect),t;try{({sessionId:t}=G(e,r));}catch{return}await ae(s,t);}async function ce(e,r){let s=T(r.dialect);await Te(s,e);}async function fr(e,r){let s=T(r.dialect),t=await ne(s,e);if(!t)return {success:false,error:new o("USER_NOT_FOUND","User not found")};let i=await K(s,e);return {success:true,user:{id:t.id,identifier:t.identifier,identifierType:t.identifierType,roles:t.roles,identifiers:i.map(n=>({id:n.id,type:n.type,value:n.value,isPrimary:n.isPrimary}))}}}async function le(e,r,s,t){let i=A(t),n=T(t.dialect),d=await ne(n,e);if(!d)return {success:false,error:new o("USER_NOT_FOUND","User not found")};if(!await V(r,d.passwordHash))return {success:false,error:new o("INVALID_CREDENTIALS","Invalid credentials")};let l=await M(s,i.saltRounds);return await ur(n,e,l),await Te(n,e),{success:true}}async function fe(e,r,s){let t=A(s),i=T(s.dialect),n=r.filter(g=>!t.validRolesSet.has(g));if(n.length>0)return {success:false,error:new o("INVALID_ROLE",`Invalid roles: ${n.join(", ")}`)};let d=await ne(i,e);if(!d)return {success:false,error:new o("USER_NOT_FOUND","User not found")};let a=new Set(d.roles);for(let g of r)a.add(g);let l=Array.from(a);return await dr(i,e,l),{success:true,user:{id:d.id,identifier:d.identifier,identifierType:d.identifierType,roles:l}}}async function pe(e,r,s){let t=T(s.dialect);if(r.length===0)return {success:false,error:new o("VALIDATION_ERROR","At least one identifier is required")};let i=r.map(l=>({type:l.type.trim(),value:l.value.trim()}));if(new Set(i.map(l=>l.value)).size!==i.length)return {success:false,error:new o("VALIDATION_ERROR","Duplicate identifier values in request")};for(let l of i)if(await Y(t,l.value))return {success:false,error:new o("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${l.value}`)};await sr(t,e,i.map(l=>({...l,isPrimary:false})));return {success:true,identifiers:(await K(t,e)).map(l=>({id:l.id,type:l.type,value:l.value,isPrimary:l.isPrimary}))}}async function me(e,r,s){let t=T(s.dialect);if(r.length===0)return {success:false,error:new o("VALIDATION_ERROR","At least one update is required")};let i=r.map(a=>({id:a.id,type:a.type.trim(),value:a.value.trim()}));if(new Set(i.map(a=>a.value)).size!==i.length)return {success:false,error:new o("VALIDATION_ERROR","Duplicate identifier values in request")};for(let a of i){let l=await oe(t,a.id,e);if(!l)return {success:false,error:new o("IDENTIFIER_NOT_FOUND",`Identifier not found: ${a.id}`)};if(l.value!==a.value&&await Y(t,a.value))return {success:false,error:new o("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${a.value}`)}}for(let a of i)await nr(t,a.id,e,{type:a.type,value:a.value});return {success:true,identifiers:(await K(t,e)).map(a=>({id:a.id,type:a.type,value:a.value,isPrimary:a.isPrimary}))}}async function ye(e,r,s){let t=T(s.dialect);if(r.length===0)return {success:false,error:new o("VALIDATION_ERROR","At least one ID is required")};for(let d of r)if(!await oe(t,d,e))return {success:false,error:new o("IDENTIFIER_NOT_FOUND",`Identifier not found: ${d}`)};return await ir(t,e)-r.length<1?{success:false,error:new o("VALIDATION_ERROR","Cannot delete all identifiers \u2014 at least one must remain")}:(await or(t,e,r),{success:true,identifiers:(await K(t,e)).map(d=>({id:d.id,type:d.type,value:d.value,isPrimary:d.isPrimary}))})}async function ge(e,r,s){let t=T(s.dialect);return await oe(t,r,e)?(await ar(t,e,r),{success:true,identifiers:(await K(t,e)).map(d=>({id:d.id,type:d.type,value:d.value,isPrimary:d.isPrimary}))}):{success:false,error:new o("IDENTIFIER_NOT_FOUND",`Identifier not found: ${r}`)}}function C(e){return e.mode==="client"?Er(e.keyUri):br(e)}function Er(e){return async(r,s,t)=>{let i=r.headers.authorization,n=i?.startsWith("Bearer ")?i.slice(7):void 0;if(!n)return t(new o("UNAUTHORIZED","Missing or malformed Authorization header"));try{let d=await He(e),a=Qe(n,d);r.user={id:a.id,identifier:a.identifier,identifierType:a.identifierType,roles:a.roles},t();}catch(d){t(d);}}}function br(e){return async(r,s,t)=>{let i=Z(r,e);if(!i)return t(new o("UNAUTHORIZED","Missing or malformed Authorization header"));try{let n=se(i,e);if(e.isTokenRevoked&&await e.isTokenRevoked(n.sessionId))return t(new o("UNAUTHORIZED","Token has been revoked"));r.user={id:n.id,identifier:n.identifier,identifierType:n.identifierType,roles:n.roles},t();}catch(n){if(n instanceof o&&n.code==="TOKEN_EXPIRED"){let d=U(r.headers.cookie,D(e));if(!d)return t(new o("UNAUTHORIZED","Token expired. Please login again."));try{let a=await H(d,e);if(!a.success)return t(new o("UNAUTHORIZED","Session expired. Please login again."));z(s,a.refreshToken,e),W(s,a.accessToken,e),s.setHeader("X-New-Access-Token",a.accessToken),r.user=a.user,t();}catch{t(new o("UNAUTHORIZED","Session expired. Please login again."));}}else t(n);}}}function ee(...e){let r=`Requires one of roles: ${e.join(", ")}`;return (s,t,i)=>{if(!s.user)return i(new o("UNAUTHORIZED","Not authenticated"));let n=s.user.roles;if(!e.some(d=>n.includes(d)))return i(new o("FORBIDDEN",r));i();}}var xr=new o("FORBIDDEN","You do not have permission to perform this action");function re(e){let r=typeof e=="function"?{check:e}:e;return async(s,t,i)=>{if(!s.user)return i(new o("UNAUTHORIZED","Not authenticated"));if(r.roles&&r.roles.length>0){let n=s.user.roles;if(r.roles.some(d=>n.includes(d)))return i()}try{let n=r.check(s);(n instanceof Promise?await n:n)?i():i(xr);}catch(n){i(n);}}}function F(e){return (r,s,t,i)=>{if(r instanceof o){t.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),t.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var he=8,j=72,Re=255,pr=100,L=50;function y(e){return new o("VALIDATION_ERROR",e)}function _(e,r,s,t){e.status(r).json({error:false,statusCode:r,message:s,data:t});}function P(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function N(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new o("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function Nr(e,r){if(!r.apiKey)return;let s=e.headers["x-api-key"];if(typeof s!="string"||s!==r.apiKey)throw new o("UNAUTHORIZED","Invalid or missing API key")}function ke(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function Dr(e){return e.startsWith("RS")||e.startsWith("PS")}function Ce(e,r){if(typeof e!="object"||e===null||Array.isArray(e))throw y(`identifiers[${r}] must be an object`);let s=e;if(typeof s.type!="string"||s.type.trim().length===0)throw y(`identifiers[${r}].type is required and must be a non-empty string`);if(s.type.length>pr)throw y(`identifiers[${r}].type must not exceed ${pr} characters`);if(typeof s.value!="string"||s.value.trim().length===0)throw y(`identifiers[${r}].value is required and must be a non-empty string`);if(s.value.length>Re)throw y(`identifiers[${r}].value must not exceed ${Re} characters`);return {type:s.type,value:s.value}}function mr(e){let r=Router(),s=e,t=A(s),i=e.router?.register??(u=>Q(u,s)),n=e.router?.login??(u=>ue(u,s)),d=e.router?.refresh??(u=>H(u,s)),a=e.router?.logout??(u=>u!==void 0?de(u,s):Promise.resolve()),l=e.router?.logoutAll??(u=>ce(u,s)),g=e.router?.assignRoles??((u,c)=>fe(u,c,s)),v=e.router?.changePassword??((u,c,w)=>le(u,c,w,s)),S=e.router?.bulkCreateIdentifiers??((u,c)=>pe(u,c,s)),k=e.router?.bulkUpdateIdentifiers??((u,c)=>me(u,c,s)),R=e.router?.bulkDeleteIdentifiers??((u,c)=>ye(u,c,s)),x=e.router?.changePrimaryIdentifier??((u,c)=>ge(u,c,s));Dr(t.algorithm)&&r.get("/keys",(u,c)=>{c.setHeader("Cache-Control","public, max-age=3600"),c.json(Ue(e.secret));}),r.post("/register",async(u,c,w)=>{try{Nr(u,e);let f=N(u.body),{identifiers:p,password:m,roles:h}=f;if(!Array.isArray(p)||p.length===0)throw y("identifiers is required and must be a non-empty array");if(p.length>L)throw y(`identifiers must not exceed ${L} entries`);let I=p.map(($,wr)=>Ce($,wr));if(typeof m!="string"||m.length<he)throw y(`password is required and must be at least ${he} characters`);if(m.length>j)throw y(`password must not exceed ${j} characters`);if(h!==void 0&&!Array.isArray(h))throw y("roles must be an array of strings when provided");if(Array.isArray(h)&&!h.every($=>typeof $=="string"))throw y("each role must be a string");let b=Array.isArray(h)?h:void 0,q=await i(b!==void 0?{identifiers:I,password:m,roles:b}:{identifiers:I,password:m});if(!q.success){P(c,q.error);return}_(c,201,"User registered successfully",{user:q.user});}catch(f){w(f);}}),r.post("/login",async(u,c,w)=>{try{let f=N(u.body),{identifier:p,password:m}=f;if(typeof p!="string"||p.trim().length===0)throw y("identifier is required and must be a non-empty string");if(p.length>Re)throw y(`identifier must not exceed ${Re} characters`);if(typeof m!="string"||m.length===0)throw y("password is required");if(m.length>j)throw y(`password must not exceed ${j} characters`);let h=p.trim(),I=await n({identifier:h,password:m});if(!I.success){ke(()=>e.hooks?.onFailedLogin?.(h,I.error)),P(c,I.error);return}ke(()=>e.hooks?.onLogin?.(I.user)),z(c,I.refreshToken,e),W(c,I.accessToken,e),_(c,200,"Login successful",{accessToken:I.accessToken,user:I.user});}catch(f){w(f);}}),r.post("/refresh",async(u,c,w)=>{try{let f=U(u.headers.cookie,D(e));if(!f)throw new o("UNAUTHORIZED","Refresh token cookie is missing");let p=await d(f);if(!p.success){ie(c,e),P(c,p.error);return}z(c,p.refreshToken,e),W(c,p.accessToken,e),_(c,200,"Token refreshed",{accessToken:p.accessToken});}catch(f){w(f);}}),r.post("/logout",async(u,c,w)=>{try{let f=U(u.headers.cookie,D(e));await a(f),ie(c,e),Ie(c,e),_(c,200,"Logged out",null);}catch(f){w(f);}}),r.post("/logout-all",C(e),async(u,c,w)=>{try{let f=u.user.id;await l(f),ke(()=>e.hooks?.onLogout?.(f)),ie(c,e),Ie(c,e),_(c,200,"All sessions revoked",null);}catch(f){w(f);}}),r.get("/me",C(e),(u,c)=>{_(c,200,"OK",u.user);});let E=re(u=>!!u.user);return r.post("/me/identifiers",C(e),E,async(u,c,w)=>{try{let f=N(u.body),{identifiers:p}=f;if(!Array.isArray(p)||p.length===0)throw y("identifiers is required and must be a non-empty array");if(p.length>L)throw y(`identifiers must not exceed ${L} entries`);let m=p.map((I,b)=>Ce(I,b)),h=await S(u.user.id,m);if(!h.success){P(c,h.error);return}_(c,201,"Identifiers added successfully",{identifiers:h.identifiers});}catch(f){w(f);}}),r.put("/me/identifiers",C(e),E,async(u,c,w)=>{try{let f=N(u.body),{identifiers:p}=f;if(!Array.isArray(p)||p.length===0)throw y("identifiers is required and must be a non-empty array");if(p.length>L)throw y(`identifiers must not exceed ${L} entries`);let m=p.map((I,b)=>{if(typeof I!="object"||I===null||Array.isArray(I))throw y(`identifiers[${b}] must be an object`);let te=I;if(typeof te.id!="string"||te.id.trim().length===0)throw y(`identifiers[${b}].id is required and must be a non-empty string`);let{type:q,value:$}=Ce(I,b);return {id:te.id,type:q,value:$}}),h=await k(u.user.id,m);if(!h.success){P(c,h.error);return}_(c,200,"Identifiers updated successfully",{identifiers:h.identifiers});}catch(f){w(f);}}),r.delete("/me/identifiers",C(e),E,async(u,c,w)=>{try{let f=N(u.body),{ids:p}=f;if(!Array.isArray(p)||p.length===0)throw y("ids is required and must be a non-empty array of strings");if(!p.every(h=>typeof h=="string"))throw y("each id must be a string");let m=await R(u.user.id,p);if(!m.success){P(c,m.error);return}_(c,200,"Identifiers deleted successfully",{identifiers:m.identifiers});}catch(f){w(f);}}),r.patch("/me/identifiers/primary",C(e),E,async(u,c,w)=>{try{let f=N(u.body),{id:p}=f;if(typeof p!="string"||p.trim().length===0)throw y("id is required and must be a non-empty string");let m=await x(u.user.id,p.trim());if(!m.success){P(c,m.error);return}_(c,200,"Primary identifier updated successfully",{identifiers:m.identifiers});}catch(f){w(f);}}),r.patch("/me/password",C(e),E,async(u,c,w)=>{try{let f=N(u.body),{currentPassword:p,newPassword:m}=f;if(typeof p!="string"||p.length===0)throw y("currentPassword is required");if(typeof m!="string"||m.length<he)throw y(`newPassword must be at least ${he} characters`);if(m.length>j)throw y(`newPassword must not exceed ${j} characters`);if(p===m)throw y("newPassword must be different from currentPassword");let h=await v(u.user.id,p,m);if(!h.success){P(c,h.error);return}_(c,200,"Password updated successfully. All sessions have been revoked.",null);}catch(f){w(f);}}),r.post("/users/:userId/roles",C(e),ee("admin"),async(u,c,w)=>{try{let f=N(u.body),{roles:p}=f,m=u.params.userId,h=typeof m=="string"?m:void 0;if(!h)throw y("userId is required");if(!Array.isArray(p)||p.length===0)throw y("roles must be a non-empty array of strings");if(!p.every(b=>typeof b=="string"))throw y("each role must be a string");let I=await g(h,p);if(!I.success){P(c,I.error);return}_(c,200,"Roles assigned successfully",{user:I.user});}catch(f){w(f);}}),r.use(F()),r}var yr=new Map;function gr(e){let r=yr.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),yr.set(e,r)),r}function _e(e){let r=e?.ttl??3e5,s=(e?.header??"X-Idempotency-Key").toLowerCase(),t=new Set((e?.methods??["POST","PUT","PATCH"]).map(n=>n.toUpperCase())),i=e?.redisUrl;return i?Ur(i,r,s,t):Kr(r,s,t,e?.maxSize??1e4)}function Ur(e,r,s,t){let i=gr(e),n="sentri:idempotency:";return async(d,a,l)=>{let g=d.headers[s];if(!g||typeof g!="string"||!t.has(d.method))return l();d.requestId=g,a.setHeader("X-Request-Id",g);let v=await i.get(`${n}${g}`);if(v){let k=JSON.parse(v);return a.setHeader("X-Idempotent-Replayed","true"),a.status(k.statusCode).json(k.body)}let S=a.json.bind(a);a.json=function(R){if(a.statusCode>=200&&a.statusCode<300){let x={statusCode:a.statusCode,body:R,expiresAt:Date.now()+r};i.set(`${n}${g}`,JSON.stringify(x),"PX",r).catch(()=>{});}return S(R)},l();}}function Kr(e,r,s,t){let i=Math.max(e,5e3),n=new Map,d=setInterval(()=>{let a=Date.now();for(let[l,g]of n)g.expiresAt<=a&&n.delete(l);},i);return typeof d=="object"&&d!==null&&"unref"in d&&d.unref(),(a,l,g)=>{let v=a.headers[r];if(!v||typeof v!="string"||!s.has(a.method))return g();a.requestId=v,l.setHeader("X-Request-Id",v);let S=Date.now(),k=n.get(v);if(k&&k.expiresAt>S)return l.setHeader("X-Idempotent-Replayed","true"),l.status(k.statusCode).json(k.body);let R=l.json.bind(l);l.json=function(E){if(l.statusCode>=200&&l.statusCode<300){if(n.size>=t){let u=n.keys().next().value;u!==void 0&&n.delete(u);}n.set(v,{statusCode:l.statusCode,body:E,expiresAt:Date.now()+e});}return R(E)},g();}}async function hr(e){await e.schema.createTable("sentri_users").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("password_hash","varchar(255)",r=>r.notNull()).addColumn("roles","text",r=>r.notNull().defaultTo("[]")).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_sessions").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("expires_at","timestamp",r=>r.notNull()).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_identifiers").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("type","varchar(100)",r=>r.notNull()).addColumn("value","varchar(255)",r=>r.notNull().unique()).addColumn("is_primary","integer",r=>r.notNull().defaultTo(0)).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute();}function Ee(e){if(Me(e),e.mode==="client")return {protect:()=>C(e),authorize:(...t)=>ee(...t),permit:t=>re(t),errorHandler:t=>F(t)};let r=e,s=A(r);return {protect:()=>C(r),authorize:(...t)=>ee(...t),permit:t=>re(t),hashPassword:t=>M(t,s.saltRounds),verifyPassword:(t,i)=>V(t,i),signAccessToken:t=>J(t,r),signRefreshToken:t=>X(t,r),verifyAccessToken:t=>se(t,r),verifyRefreshToken:t=>G(t,r),getCurrentAccessToken:t=>Z(t,r),router:()=>mr(r),migrate:()=>hr(T(r.dialect)),errorHandler:t=>F(t),idempotencyMiddleware:t=>_e({...t,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:t=>Q(t,r),login:t=>ue(t,r),refresh:t=>H(t,r),logout:t=>de(t,r),logoutAll:t=>ce(t,r),getUser:t=>fr(t,r),changePassword:(t,i,n)=>le(t,i,n,r),assignRoles:(t,i)=>fe(t,i,r),bulkCreateIdentifiers:(t,i)=>pe(t,i,r),bulkUpdateIdentifiers:(t,i)=>me(t,i,r),bulkDeleteIdentifiers:(t,i)=>ye(t,i,r),changePrimaryIdentifier:(t,i)=>ge(t,i,r)}}function Rr(e){return new PostgresDialect({pool:new Pool(e)})}function Lr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),s={mode:"server",dialect:Rr(e.db),secret:r,algorithm:"RS256",validRoles:e.validRoles,...e.accessExpiresIn!==void 0&&{accessExpiresIn:e.accessExpiresIn},...e.refreshExpiresIn!==void 0&&{refreshExpiresIn:e.refreshExpiresIn},...e.saltRounds!==void 0&&{saltRounds:e.saltRounds},...e.apiKey!==void 0&&{apiKey:e.apiKey},...e.cookie!==void 0&&{cookie:e.cookie},...e.accessCookie!==void 0&&{accessCookie:e.accessCookie},...e.hooks!==void 0&&{hooks:e.hooks},...e.router!==void 0&&{router:e.router},...e.isTokenRevoked!==void 0&&{isTokenRevoked:e.isTokenRevoked},...e.redisUrl!==void 0&&{redisUrl:e.redisUrl}};return Ee(s)}export{be as SENTRI_ERROR_STATUS,o as SentriError,Ee as createAuth,Lr as createAuthServer,F as createErrorHandler,_e as createIdempotencyMiddleware,Z as getCurrentAccessToken,Q as register};
|