sentri 2.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -17
- package/dist/cli.js +19 -7
- package/dist/index.d.ts +135 -17
- package/dist/index.js +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -14,12 +14,14 @@ 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)
|
|
20
21
|
- [Token Utilities](#token-utilities)
|
|
21
22
|
- [Error Handling](#error-handling)
|
|
22
23
|
- [Request Idempotency](#request-idempotency)
|
|
24
|
+
- [Logger](#logger)
|
|
23
25
|
- [Migration Guide](#migration-guide)
|
|
24
26
|
|
|
25
27
|
---
|
|
@@ -165,7 +167,8 @@ createAuth({
|
|
|
165
167
|
isTokenRevoked: async (sessionId) => await redis.sismember('revoked', sessionId),
|
|
166
168
|
router: { // override built-in service functions
|
|
167
169
|
login, register, refresh, logout, logoutAll, assignRoles,
|
|
168
|
-
|
|
170
|
+
bulkCreateIdentifiers, bulkUpdateIdentifiers, bulkDeleteIdentifiers,
|
|
171
|
+
changePrimaryIdentifier, changePassword,
|
|
169
172
|
},
|
|
170
173
|
});
|
|
171
174
|
```
|
|
@@ -177,9 +180,10 @@ createAuth({
|
|
|
177
180
|
await auth.migrate();
|
|
178
181
|
```
|
|
179
182
|
|
|
180
|
-
Creates
|
|
181
|
-
- `sentri_users` — id,
|
|
183
|
+
Creates three tables:
|
|
184
|
+
- `sentri_users` — id, password_hash, roles (JSON), created_at
|
|
182
185
|
- `sentri_sessions` — id, user_id, expires_at, created_at
|
|
186
|
+
- `sentri_identifiers` — id, user_id, type, value (globally unique), is_primary, created_at
|
|
183
187
|
|
|
184
188
|
---
|
|
185
189
|
|
|
@@ -223,6 +227,143 @@ Client apps point `keyUri` at this endpoint and receive the public key automatic
|
|
|
223
227
|
|
|
224
228
|
---
|
|
225
229
|
|
|
230
|
+
## Multi-Identifier
|
|
231
|
+
|
|
232
|
+
Each user can have multiple identifiers — email, username, phone number, or any custom type. All identifier values are **globally unique** regardless of type.
|
|
233
|
+
|
|
234
|
+
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.
|
|
235
|
+
|
|
236
|
+
### Registration
|
|
237
|
+
|
|
238
|
+
Provide at least one identifier. The first entry becomes the primary.
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
POST /register
|
|
242
|
+
Content-Type: application/json
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
"identifiers": [
|
|
246
|
+
{ "type": "email", "value": "rizz@example.com" },
|
|
247
|
+
{ "type": "username", "value": "rizz" }
|
|
248
|
+
],
|
|
249
|
+
"password": "secret123",
|
|
250
|
+
"roles": ["user"]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Response:**
|
|
255
|
+
```json
|
|
256
|
+
{
|
|
257
|
+
"error": false,
|
|
258
|
+
"statusCode": 201,
|
|
259
|
+
"message": "User registered successfully",
|
|
260
|
+
"data": {
|
|
261
|
+
"user": {
|
|
262
|
+
"id": "uuid",
|
|
263
|
+
"identifier": "rizz@example.com",
|
|
264
|
+
"identifierType": "email",
|
|
265
|
+
"roles": ["user"],
|
|
266
|
+
"identifiers": [
|
|
267
|
+
{ "id": "uuid-1", "type": "email", "value": "rizz@example.com", "isPrimary": true },
|
|
268
|
+
{ "id": "uuid-2", "type": "username", "value": "rizz", "isPrimary": false }
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Login
|
|
276
|
+
|
|
277
|
+
Send any of the user's identifier values — Sentri searches all types automatically.
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
POST /login
|
|
281
|
+
Content-Type: application/json
|
|
282
|
+
|
|
283
|
+
{ "identifier": "rizz", "password": "secret123" }
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Bulk Create Identifiers
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
POST /me/identifiers
|
|
290
|
+
Authorization: Bearer <token>
|
|
291
|
+
Content-Type: application/json
|
|
292
|
+
|
|
293
|
+
{
|
|
294
|
+
"identifiers": [
|
|
295
|
+
{ "type": "phone", "value": "+628123456789" }
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Bulk Update Identifiers
|
|
301
|
+
|
|
302
|
+
```
|
|
303
|
+
PUT /me/identifiers
|
|
304
|
+
Authorization: Bearer <token>
|
|
305
|
+
Content-Type: application/json
|
|
306
|
+
|
|
307
|
+
{
|
|
308
|
+
"identifiers": [
|
|
309
|
+
{ "id": "uuid-2", "type": "username", "value": "newrizz" }
|
|
310
|
+
]
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Bulk Delete Identifiers
|
|
315
|
+
|
|
316
|
+
```
|
|
317
|
+
DELETE /me/identifiers
|
|
318
|
+
Authorization: Bearer <token>
|
|
319
|
+
Content-Type: application/json
|
|
320
|
+
|
|
321
|
+
{ "ids": ["uuid-2"] }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
At least one identifier must remain after deletion.
|
|
325
|
+
|
|
326
|
+
### Change Primary Identifier
|
|
327
|
+
|
|
328
|
+
```
|
|
329
|
+
PATCH /me/identifiers/primary
|
|
330
|
+
Authorization: Bearer <token>
|
|
331
|
+
Content-Type: application/json
|
|
332
|
+
|
|
333
|
+
{ "id": "uuid-2" }
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The new primary value will be embedded in the JWT on the next login or token refresh.
|
|
337
|
+
|
|
338
|
+
### Programmatic API
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
const auth = createAuth({ mode: 'server', ... });
|
|
342
|
+
|
|
343
|
+
// Register with multiple identifiers
|
|
344
|
+
await auth.register({
|
|
345
|
+
identifiers: [
|
|
346
|
+
{ type: 'email', value: 'rizz@example.com' },
|
|
347
|
+
{ type: 'username', value: 'rizz' },
|
|
348
|
+
],
|
|
349
|
+
password: 'secret123',
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// Add identifiers after registration
|
|
353
|
+
await auth.bulkCreateIdentifiers(userId, [{ type: 'phone', value: '+628123456789' }]);
|
|
354
|
+
|
|
355
|
+
// Update identifiers
|
|
356
|
+
await auth.bulkUpdateIdentifiers(userId, [{ id: 'uuid-2', type: 'username', value: 'newrizz' }]);
|
|
357
|
+
|
|
358
|
+
// Delete identifiers
|
|
359
|
+
await auth.bulkDeleteIdentifiers(userId, ['uuid-2']);
|
|
360
|
+
|
|
361
|
+
// Change primary
|
|
362
|
+
await auth.changePrimaryIdentifier(userId, 'uuid-3');
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
226
367
|
## Configuration
|
|
227
368
|
|
|
228
369
|
### `algorithm`
|
|
@@ -259,8 +400,11 @@ After login, both cookies are set automatically. `protect()` reads the access to
|
|
|
259
400
|
| `POST` | `/refresh` | — | Rotate refresh token |
|
|
260
401
|
| `POST` | `/logout` | — | Invalidate current session |
|
|
261
402
|
| `POST` | `/logout-all` | ✓ | Invalidate all sessions |
|
|
262
|
-
| `GET` | `/me` | ✓ | Return authenticated user |
|
|
263
|
-
| `
|
|
403
|
+
| `GET` | `/me` | ✓ | Return authenticated user with all identifiers |
|
|
404
|
+
| `POST` | `/me/identifiers` | ✓ self | Add identifiers in bulk |
|
|
405
|
+
| `PUT` | `/me/identifiers` | ✓ self | Update identifiers in bulk |
|
|
406
|
+
| `DELETE` | `/me/identifiers` | ✓ self | Delete identifiers in bulk |
|
|
407
|
+
| `PATCH` | `/me/identifiers/primary` | ✓ self | Change primary identifier |
|
|
264
408
|
| `PATCH` | `/me/password` | ✓ self | Change password — revokes all sessions |
|
|
265
409
|
| `POST` | `/users/:userId/roles` | ✓ admin | Assign roles to user |
|
|
266
410
|
| `GET` | `/keys` | — | Public key in JWKS format (RS256 only) |
|
|
@@ -270,16 +414,6 @@ All responses use the envelope:
|
|
|
270
414
|
{ "error": false, "statusCode": 200, "message": "...", "data": { ... } }
|
|
271
415
|
```
|
|
272
416
|
|
|
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
417
|
### Change Password
|
|
284
418
|
|
|
285
419
|
```
|
|
@@ -302,6 +436,7 @@ Verifies the JWT and sets `req.user`. In server mode, also performs silent token
|
|
|
302
436
|
|
|
303
437
|
```typescript
|
|
304
438
|
app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
|
|
439
|
+
// req.user: { id, identifier, identifierType, roles, identifiers? }
|
|
305
440
|
```
|
|
306
441
|
|
|
307
442
|
Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
|
|
@@ -348,7 +483,7 @@ Available on `ServerAuthClient` only:
|
|
|
348
483
|
const auth = createAuth({ mode: 'server', ... });
|
|
349
484
|
|
|
350
485
|
// Sign
|
|
351
|
-
const accessToken = auth.signAccessToken({ id, identifier, roles });
|
|
486
|
+
const accessToken = auth.signAccessToken({ id, identifier, identifierType, roles });
|
|
352
487
|
const refreshToken = auth.signRefreshToken(sessionId);
|
|
353
488
|
|
|
354
489
|
// Verify
|
|
@@ -376,8 +511,10 @@ app.use(auth.errorHandler()); // must be last
|
|
|
376
511
|
| Code | HTTP | Meaning |
|
|
377
512
|
|---|---|---|
|
|
378
513
|
| `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
|
|
379
|
-
| `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
|
|
380
514
|
| `USER_NOT_FOUND` | 404 | User does not exist |
|
|
515
|
+
| `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
|
|
516
|
+
| `IDENTIFIER_NOT_FOUND` | 404 | Referenced identifier ID does not exist or belongs to another user |
|
|
517
|
+
| `IDENTIFIER_ALREADY_EXISTS` | 409 | Identifier value already taken by another user |
|
|
381
518
|
| `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
|
|
382
519
|
| `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
|
|
383
520
|
| `UNAUTHORIZED` | 401 | No valid token or session not found |
|
|
@@ -453,8 +590,151 @@ app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
|
|
|
453
590
|
|
|
454
591
|
---
|
|
455
592
|
|
|
593
|
+
## Logger
|
|
594
|
+
|
|
595
|
+
Sentri produces structured JSON log entries for every auth event. Logging is **opt-in** — when no logger is configured, Sentri is completely silent (zero overhead).
|
|
596
|
+
|
|
597
|
+
### Activating the logger
|
|
598
|
+
|
|
599
|
+
Pass any object that implements `{ info, warn, error }` via the `logger` field in your config.
|
|
600
|
+
|
|
601
|
+
**pino** (recommended for production):
|
|
602
|
+
```typescript
|
|
603
|
+
import pino from 'pino';
|
|
604
|
+
|
|
605
|
+
const auth = createAuth({
|
|
606
|
+
mode: 'server',
|
|
607
|
+
// ...other config
|
|
608
|
+
logger: pino(),
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
**winston**:
|
|
613
|
+
```typescript
|
|
614
|
+
import winston from 'winston';
|
|
615
|
+
|
|
616
|
+
const auth = createAuth({
|
|
617
|
+
mode: 'server',
|
|
618
|
+
// ...other config
|
|
619
|
+
logger: winston.createLogger({
|
|
620
|
+
transports: [new winston.transports.Console()],
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
**console** (zero setup, good for development):
|
|
626
|
+
```typescript
|
|
627
|
+
const auth = createAuth({
|
|
628
|
+
mode: 'server',
|
|
629
|
+
// ...other config
|
|
630
|
+
logger: console,
|
|
631
|
+
});
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
Works identically in **client mode**:
|
|
635
|
+
```typescript
|
|
636
|
+
const auth = createAuth({
|
|
637
|
+
mode: 'client',
|
|
638
|
+
keyUri: 'https://auth.myapp.com/auth/keys',
|
|
639
|
+
logger: pino(),
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### Customising the service name
|
|
644
|
+
|
|
645
|
+
By default every log entry contains `"service": "sentri"`. Override it with `loggerService`:
|
|
646
|
+
|
|
647
|
+
```typescript
|
|
648
|
+
const auth = createAuth({
|
|
649
|
+
// ...
|
|
650
|
+
logger: pino(),
|
|
651
|
+
loggerService: 'auth-service',
|
|
652
|
+
});
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
### Request ID correlation
|
|
656
|
+
|
|
657
|
+
If you mount a request-ID middleware **before** Sentri, the `requestId` is automatically included in every log entry for that request:
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
import { randomUUID } from 'crypto';
|
|
661
|
+
|
|
662
|
+
app.use((req, _res, next) => {
|
|
663
|
+
req.requestId = randomUUID();
|
|
664
|
+
next();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
app.use('/auth', auth.router());
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Sample pino output:
|
|
671
|
+
```json
|
|
672
|
+
{"level":30,"time":1719484800000,"service":"sentri","event":"auth.login.success","userId":"usr_abc","duration_ms":42,"requestId":"d4e5f6"}
|
|
673
|
+
{"level":40,"time":1719484801000,"service":"sentri","event":"auth.authorize.denied","userId":"usr_abc","userRoles":["user"],"requiredRoles":["admin"],"requestId":"d4e5f7"}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
### Log events
|
|
677
|
+
|
|
678
|
+
| Event | Level | Emitted by | Key fields |
|
|
679
|
+
|-------|-------|------------|-----------|
|
|
680
|
+
| `auth.protect.success` | info | `protect()` | `userId`, `mode` |
|
|
681
|
+
| `auth.protect.failure` | warn | `protect()` | `errorCode`, `mode` |
|
|
682
|
+
| `auth.protect.token_revoked` | warn | `protect()` | `userId`, `mode` |
|
|
683
|
+
| `auth.protect.auto_refresh` | info | `protect()` | `userId`, `mode` |
|
|
684
|
+
| `auth.authorize.passed` | info | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
|
|
685
|
+
| `auth.authorize.denied` | warn | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
|
|
686
|
+
| `auth.authorize.unauthenticated` | warn | `authorize()` | `requiredRoles` |
|
|
687
|
+
| `auth.permit.passed` | info | `permit()` | `userId` |
|
|
688
|
+
| `auth.permit.denied` | warn | `permit()` | `userId` |
|
|
689
|
+
| `auth.permit.role_bypass` | info | `permit()` | `userId`, `bypassedByRole` |
|
|
690
|
+
| `auth.permit.unauthenticated` | warn | `permit()` | — |
|
|
691
|
+
| `auth.register.success` | info | router | `userId`, `duration_ms` |
|
|
692
|
+
| `auth.register.failure` | warn | router | `errorCode`, `duration_ms` |
|
|
693
|
+
| `auth.login.success` | info | router | `userId`, `duration_ms` |
|
|
694
|
+
| `auth.login.failure` | warn | router | `errorCode`, `duration_ms` |
|
|
695
|
+
| `auth.refresh.success` | info | router | `userId`, `duration_ms` |
|
|
696
|
+
| `auth.refresh.failure` | warn | router | `errorCode`, `duration_ms` |
|
|
697
|
+
| `auth.logout` | info | router | `duration_ms` |
|
|
698
|
+
| `auth.logout_all` | info | router | `userId`, `duration_ms` |
|
|
699
|
+
| `auth.password.changed` | info | router | `userId`, `duration_ms` |
|
|
700
|
+
| `auth.password.change_failure` | warn | router | `userId`, `errorCode`, `duration_ms` |
|
|
701
|
+
| `auth.roles.assigned` | info | router | `targetUserId`, `roles`, `duration_ms` |
|
|
702
|
+
| `auth.roles.assign_failure` | warn | router | `targetUserId`, `errorCode`, `duration_ms` |
|
|
703
|
+
| `auth.identifiers.created` | info | router | `userId`, `count`, `duration_ms` |
|
|
704
|
+
| `auth.identifiers.updated` | info | router | `userId`, `count`, `duration_ms` |
|
|
705
|
+
| `auth.identifiers.deleted` | info | router | `userId`, `duration_ms` |
|
|
706
|
+
| `auth.identifiers.primary_changed` | info | router | `userId`, `duration_ms` |
|
|
707
|
+
|
|
708
|
+
All entries include `service` (configurable via `loggerService`) and `requestId` when available.
|
|
709
|
+
|
|
710
|
+
### `SentriLogger` interface
|
|
711
|
+
|
|
712
|
+
```typescript
|
|
713
|
+
import type { SentriLogger } from 'sentri';
|
|
714
|
+
|
|
715
|
+
const myLogger: SentriLogger = {
|
|
716
|
+
info(data: Record<string, unknown>) { /* ... */ },
|
|
717
|
+
warn(data: Record<string, unknown>) { /* ... */ },
|
|
718
|
+
error(data: Record<string, unknown>) { /* ... */ },
|
|
719
|
+
};
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
---
|
|
723
|
+
|
|
456
724
|
## Migration Guide
|
|
457
725
|
|
|
726
|
+
### 4.0.0 Breaking Changes
|
|
727
|
+
|
|
728
|
+
| What changed | Action required |
|
|
729
|
+
|---|---|
|
|
730
|
+
| `sentri_users` no longer has an `identifier` column | Drop and recreate tables — identities now live in `sentri_identifiers` |
|
|
731
|
+
| New `sentri_identifiers` table | Created automatically by `auth.migrate()` |
|
|
732
|
+
| `RegisterInput.identifier` → `RegisterInput.identifiers` (array) | Update register calls to pass `identifiers: [{ type, value }]` |
|
|
733
|
+
| `AuthUser` now includes `identifierType` | Update any code reading `req.user` to expect this new field |
|
|
734
|
+
| `PATCH /me/identifier` removed | Use `PUT /me/identifiers` for updates, `PATCH /me/identifiers/primary` for primary change |
|
|
735
|
+
| `changeIdentifier()` removed from `ServerAuthClient` | Use `bulkUpdateIdentifiers()` or `changePrimaryIdentifier()` |
|
|
736
|
+
| New error codes: `IDENTIFIER_NOT_FOUND`, `IDENTIFIER_ALREADY_EXISTS` | Handle these in your error handlers as needed |
|
|
737
|
+
|
|
458
738
|
### 3.0.0 Breaking Changes
|
|
459
739
|
|
|
460
740
|
| What changed | Action required |
|
package/dist/cli.js
CHANGED
|
@@ -34,6 +34,12 @@ export const sentriAuth = createAuthServer<Role>({
|
|
|
34
34
|
|
|
35
35
|
// -- Token revocation (optional) --------------------------------------------
|
|
36
36
|
// isTokenRevoked: async (sessionId) => false,
|
|
37
|
+
|
|
38
|
+
// -- Logger (optional) ------------------------------------------------------
|
|
39
|
+
// Accepts any logger compatible with pino, winston, or console.
|
|
40
|
+
// When omitted, sentri produces no log output.
|
|
41
|
+
// logger: console,
|
|
42
|
+
// loggerService: 'sentri', // default: 'sentri'
|
|
37
43
|
});
|
|
38
44
|
`,d=`# -- Server -------------------------------------------------------------------
|
|
39
45
|
PORT=3000
|
|
@@ -66,14 +72,20 @@ export const sentriAuth = createAuth<Role>({
|
|
|
66
72
|
// -- Roles (optional) -------------------------------------------------------
|
|
67
73
|
// Only used for TypeScript type safety on authorize() \u2014 not validated at runtime.
|
|
68
74
|
validRoles: ['admin', 'user'],
|
|
75
|
+
|
|
76
|
+
// -- Logger (optional) ------------------------------------------------------
|
|
77
|
+
// Accepts any logger compatible with pino, winston, or console.
|
|
78
|
+
// When omitted, sentri produces no log output.
|
|
79
|
+
// logger: console,
|
|
80
|
+
// loggerService: 'auth-service', // default: 'sentri'
|
|
69
81
|
});
|
|
70
|
-
`,
|
|
82
|
+
`,g=`# -- Server -------------------------------------------------------------------
|
|
71
83
|
PORT=3000
|
|
72
84
|
|
|
73
85
|
# -- Auth Server --------------------------------------------------------------
|
|
74
86
|
# URL of the auth server's GET /auth/keys endpoint (JWKS).
|
|
75
87
|
AUTH_KEY_URI=http://localhost:3000/auth/keys
|
|
76
|
-
`;function
|
|
88
|
+
`;function a(){console.log(`
|
|
77
89
|
sentri \u2014 auth/authorization library for Express
|
|
78
90
|
|
|
79
91
|
Usage:
|
|
@@ -86,9 +98,9 @@ Commands:
|
|
|
86
98
|
Examples:
|
|
87
99
|
npx sentri init server
|
|
88
100
|
npx sentri init client
|
|
89
|
-
`);}async function
|
|
101
|
+
`);}async function s(e,o,n){if(existsSync(e)){console.log(` skip ${n} (already exists)`);return}await writeFile(e,o,"utf8"),console.log(` create ${n}`);}async function v(){let e=process.cwd(),o=join(e,"src","lib");await mkdir(o,{recursive:true}),console.log(`
|
|
90
102
|
Generating sentri server mode files...
|
|
91
|
-
`),await
|
|
103
|
+
`),await s(join(o,"sentri.ts"),u,"src/lib/sentri.ts"),await s(join(e,".env.example"),d,".env.example"),console.log(`
|
|
92
104
|
Done. Next steps:
|
|
93
105
|
|
|
94
106
|
1. Copy .env.example to .env and fill in your values
|
|
@@ -103,9 +115,9 @@ Done. Next steps:
|
|
|
103
115
|
3. Protect routes:
|
|
104
116
|
|
|
105
117
|
app.get('/me', sentriAuth.protect(), (req, res) => res.json(req.user));
|
|
106
|
-
`);}async function
|
|
118
|
+
`);}async function h(){let e=process.cwd(),o=join(e,"src","lib");await mkdir(o,{recursive:true}),console.log(`
|
|
107
119
|
Generating sentri client mode files...
|
|
108
|
-
`),await
|
|
120
|
+
`),await s(join(o,"sentri.ts"),m,"src/lib/sentri.ts"),await s(join(e,".env.example"),g,".env.example"),console.log(`
|
|
109
121
|
Done. Next steps:
|
|
110
122
|
|
|
111
123
|
1. Copy .env.example to .env and set AUTH_KEY_URI to your auth server's /auth/keys endpoint
|
|
@@ -115,4 +127,4 @@ Done. Next steps:
|
|
|
115
127
|
|
|
116
128
|
app.get('/protected', sentriAuth.protect(), (req, res) => res.json(req.user));
|
|
117
129
|
app.use(sentriAuth.errorHandler());
|
|
118
|
-
`);}var
|
|
130
|
+
`);}var E=process.argv.slice(2),[r,i]=E;(!r||r==="--help"||r==="-h")&&(a(),process.exit(0));r==="init"?i==="server"?v().catch(e=>{console.error(e),process.exit(1);}):i==="client"?h().catch(e=>{console.error(e),process.exit(1);}):(console.error("Usage: npx sentri init <server|client>"),process.exit(1)):(console.error(`Unknown command: ${r}`),a(),process.exit(1));
|
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.
|
|
@@ -91,16 +93,73 @@ declare class SentriError extends Error {
|
|
|
91
93
|
constructor(code: SentriErrorCode | (string & {}), message: string, statusCode?: number);
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Minimal structured logger interface accepted by Sentri.
|
|
98
|
+
*
|
|
99
|
+
* Compatible out-of-the-box with **pino**, **winston**, and **console** — all
|
|
100
|
+
* three expose `info`, `warn`, and `error` methods that accept an object argument.
|
|
101
|
+
*
|
|
102
|
+
* Pass an instance via `logger` in `ServerAuthConfig` or `ClientAuthConfig`.
|
|
103
|
+
* When omitted, Sentri produces no log output (no-op default).
|
|
104
|
+
*
|
|
105
|
+
* Each call receives a plain `Record<string, unknown>` containing structured
|
|
106
|
+
* fields — never a pre-formatted string — so your logger controls serialisation,
|
|
107
|
+
* output destination, and timestamp format.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* // pino
|
|
111
|
+
* import pino from 'pino';
|
|
112
|
+
* const auth = createAuth({ ..., logger: pino() });
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* // winston
|
|
116
|
+
* import winston from 'winston';
|
|
117
|
+
* const auth = createAuth({ ..., logger: winston.createLogger({ ... }) });
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* // console (zero setup, useful for development)
|
|
121
|
+
* const auth = createAuth({ ..., logger: console });
|
|
122
|
+
*/
|
|
123
|
+
interface SentriLogger {
|
|
124
|
+
info(data: Record<string, unknown>): void;
|
|
125
|
+
warn(data: Record<string, unknown>): void;
|
|
126
|
+
error(data: Record<string, unknown>): void;
|
|
127
|
+
}
|
|
128
|
+
|
|
94
129
|
interface ApiResponse<T = null> {
|
|
95
130
|
error: boolean;
|
|
96
131
|
statusCode: number;
|
|
97
132
|
message: string;
|
|
98
133
|
data: T | null;
|
|
99
134
|
}
|
|
135
|
+
/** A single identifier entry belonging to a user. */
|
|
136
|
+
interface IdentifierRecord {
|
|
137
|
+
id: string;
|
|
138
|
+
type: string;
|
|
139
|
+
value: string;
|
|
140
|
+
isPrimary: boolean;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* The authenticated user shape. `identifier` and `identifierType` always
|
|
144
|
+
* reflect the primary identifier. `identifiers` is populated only when the
|
|
145
|
+
* full user object is fetched (e.g. GET /me) and is absent from JWT payloads.
|
|
146
|
+
*/
|
|
100
147
|
interface AuthUser<TRole extends string = string> {
|
|
101
148
|
id: string;
|
|
149
|
+
/** Primary identifier value — embedded in the JWT payload. */
|
|
102
150
|
identifier: string;
|
|
151
|
+
/** Type of the primary identifier (e.g. 'email', 'username'). */
|
|
152
|
+
identifierType: string;
|
|
103
153
|
roles: TRole[];
|
|
154
|
+
/** All identifiers for this user. Only present in full user responses, not in JWT. */
|
|
155
|
+
identifiers?: IdentifierRecord[];
|
|
156
|
+
}
|
|
157
|
+
/** Input for a single identifier entry. */
|
|
158
|
+
interface IdentifierInput {
|
|
159
|
+
/** Arbitrary label such as 'email', 'username', or 'phone'. */
|
|
160
|
+
type: string;
|
|
161
|
+
/** The globally unique identifier value. */
|
|
162
|
+
value: string;
|
|
104
163
|
}
|
|
105
164
|
type RegisterResult<TRole extends string = string> = {
|
|
106
165
|
success: true;
|
|
@@ -132,9 +191,16 @@ type GetUserResult<TRole extends string = string> = {
|
|
|
132
191
|
success: false;
|
|
133
192
|
error: SentriError;
|
|
134
193
|
};
|
|
135
|
-
type
|
|
194
|
+
type BulkIdentifiersResult = {
|
|
136
195
|
success: true;
|
|
137
|
-
|
|
196
|
+
identifiers: IdentifierRecord[];
|
|
197
|
+
} | {
|
|
198
|
+
success: false;
|
|
199
|
+
error: SentriError;
|
|
200
|
+
};
|
|
201
|
+
type ChangePrimaryResult = {
|
|
202
|
+
success: true;
|
|
203
|
+
identifiers: IdentifierRecord[];
|
|
138
204
|
} | {
|
|
139
205
|
success: false;
|
|
140
206
|
error: SentriError;
|
|
@@ -155,11 +221,17 @@ type RefreshResult<TRole extends string = string> = {
|
|
|
155
221
|
error: SentriError;
|
|
156
222
|
};
|
|
157
223
|
interface RegisterInput<TRole extends string = string> {
|
|
158
|
-
|
|
224
|
+
/**
|
|
225
|
+
* One or more identifiers for the new user. The first entry becomes the
|
|
226
|
+
* primary identifier (embedded in the JWT payload). All values must be
|
|
227
|
+
* globally unique. At least one identifier is required.
|
|
228
|
+
*/
|
|
229
|
+
identifiers: IdentifierInput[];
|
|
159
230
|
password: string;
|
|
160
231
|
roles?: TRole[];
|
|
161
232
|
}
|
|
162
233
|
interface LoginInput {
|
|
234
|
+
/** Any of the user's identifier values — Sentri searches all types. */
|
|
163
235
|
identifier: string;
|
|
164
236
|
password: string;
|
|
165
237
|
}
|
|
@@ -188,7 +260,14 @@ interface RouterHandlers {
|
|
|
188
260
|
logout?: (refreshToken: string | undefined) => Promise<void>;
|
|
189
261
|
logoutAll?: (userId: string) => Promise<void>;
|
|
190
262
|
assignRoles?: (userId: string, roles: string[]) => Promise<AssignRolesResult>;
|
|
191
|
-
|
|
263
|
+
bulkCreateIdentifiers?: (userId: string, identifiers: IdentifierInput[]) => Promise<BulkIdentifiersResult>;
|
|
264
|
+
bulkUpdateIdentifiers?: (userId: string, updates: Array<{
|
|
265
|
+
id: string;
|
|
266
|
+
type: string;
|
|
267
|
+
value: string;
|
|
268
|
+
}>) => Promise<BulkIdentifiersResult>;
|
|
269
|
+
bulkDeleteIdentifiers?: (userId: string, ids: string[]) => Promise<BulkIdentifiersResult>;
|
|
270
|
+
changePrimaryIdentifier?: (userId: string, identifierId: string) => Promise<ChangePrimaryResult>;
|
|
192
271
|
changePassword?: (userId: string, currentPassword: string, newPassword: string) => Promise<ChangePasswordResult>;
|
|
193
272
|
}
|
|
194
273
|
interface ServerAuthConfig<TRole extends string = string> {
|
|
@@ -226,6 +305,16 @@ interface ServerAuthConfig<TRole extends string = string> {
|
|
|
226
305
|
* instead of an in-memory Map — required for multi-process deployments.
|
|
227
306
|
*/
|
|
228
307
|
redisUrl?: string;
|
|
308
|
+
/**
|
|
309
|
+
* Structured logger instance. Compatible with pino, winston, or console.
|
|
310
|
+
* When omitted, Sentri produces no log output.
|
|
311
|
+
*/
|
|
312
|
+
logger?: SentriLogger;
|
|
313
|
+
/**
|
|
314
|
+
* Service name embedded in every log entry.
|
|
315
|
+
* @default 'sentri'
|
|
316
|
+
*/
|
|
317
|
+
loggerService?: string;
|
|
229
318
|
}
|
|
230
319
|
interface ClientAuthConfig<TRole extends string = string> {
|
|
231
320
|
mode: 'client';
|
|
@@ -233,6 +322,16 @@ interface ClientAuthConfig<TRole extends string = string> {
|
|
|
233
322
|
keyUri: string;
|
|
234
323
|
/** Optional — only needed for TypeScript type safety on authorize(). */
|
|
235
324
|
validRoles?: readonly TRole[];
|
|
325
|
+
/**
|
|
326
|
+
* Structured logger instance. Compatible with pino, winston, or console.
|
|
327
|
+
* When omitted, Sentri produces no log output.
|
|
328
|
+
*/
|
|
329
|
+
logger?: SentriLogger;
|
|
330
|
+
/**
|
|
331
|
+
* Service name embedded in every log entry.
|
|
332
|
+
* @default 'sentri'
|
|
333
|
+
*/
|
|
334
|
+
loggerService?: string;
|
|
236
335
|
}
|
|
237
336
|
type AuthConfig<TRole extends string = string> = ServerAuthConfig<TRole> | ClientAuthConfig<TRole>;
|
|
238
337
|
|
|
@@ -406,15 +505,13 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
|
|
|
406
505
|
/** Extract the raw access token from an Express request. */
|
|
407
506
|
getCurrentAccessToken(request: Request): string | undefined;
|
|
408
507
|
/**
|
|
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)
|
|
508
|
+
* Pre-built Express Router with auth endpoints.
|
|
509
|
+
* See README for the full endpoint list.
|
|
413
510
|
*/
|
|
414
511
|
router(): Router;
|
|
415
512
|
/**
|
|
416
|
-
* Run database migrations to create sentri_users
|
|
417
|
-
* Safe to call on every startup — uses IF NOT EXISTS.
|
|
513
|
+
* Run database migrations to create sentri_users, sentri_sessions, and
|
|
514
|
+
* sentri_identifiers tables. Safe to call on every startup — uses IF NOT EXISTS.
|
|
418
515
|
*/
|
|
419
516
|
migrate(): Promise<void>;
|
|
420
517
|
/**
|
|
@@ -423,9 +520,9 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
|
|
|
423
520
|
* Uses Redis backend when `redisUrl` is set in server config; otherwise in-memory.
|
|
424
521
|
*/
|
|
425
522
|
idempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
|
|
426
|
-
/** Register a new user. */
|
|
523
|
+
/** Register a new user with one or more identifiers. */
|
|
427
524
|
register(input: RegisterInput<TRole>): Promise<RegisterResult<TRole>>;
|
|
428
|
-
/** Authenticate a user, returns access + refresh tokens. */
|
|
525
|
+
/** Authenticate a user by any of their identifier values, returns access + refresh tokens. */
|
|
429
526
|
login(input: LoginInput): Promise<AuthResult<TRole>>;
|
|
430
527
|
/** Rotate a refresh token, returns new token pair. */
|
|
431
528
|
refresh(refreshToken: string): Promise<RefreshResult<TRole>>;
|
|
@@ -433,14 +530,24 @@ interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRo
|
|
|
433
530
|
logout(refreshToken: string): Promise<void>;
|
|
434
531
|
/** Invalidate all sessions for a user. */
|
|
435
532
|
logoutAll(userId: string): Promise<void>;
|
|
436
|
-
/** Fetch a user by ID. Returns `success: false` if not found. */
|
|
533
|
+
/** Fetch a user by ID, including all their identifiers. Returns `success: false` if not found. */
|
|
437
534
|
getUser(userId: string): Promise<GetUserResult<TRole>>;
|
|
438
|
-
/** Change a user's identifier (username/email). */
|
|
439
|
-
changeIdentifier(userId: string, newIdentifier: string): Promise<ChangeIdentifierResult<TRole>>;
|
|
440
535
|
/** Change a user's password and revoke all their sessions. */
|
|
441
536
|
changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
|
|
442
537
|
/** Add roles to a user (existing roles are preserved). */
|
|
443
538
|
assignRoles(userId: string, roles: TRole[]): Promise<AssignRolesResult<TRole>>;
|
|
539
|
+
/** Add new identifiers to a user in bulk. Returns the full updated identifier list. */
|
|
540
|
+
bulkCreateIdentifiers(userId: string, identifiers: IdentifierInput[]): Promise<BulkIdentifiersResult>;
|
|
541
|
+
/** Update type and value for multiple identifiers in bulk. Returns the full updated identifier list. */
|
|
542
|
+
bulkUpdateIdentifiers(userId: string, updates: Array<{
|
|
543
|
+
id: string;
|
|
544
|
+
type: string;
|
|
545
|
+
value: string;
|
|
546
|
+
}>): Promise<BulkIdentifiersResult>;
|
|
547
|
+
/** Delete multiple identifiers by ID. At least one identifier must remain. Returns the remaining list. */
|
|
548
|
+
bulkDeleteIdentifiers(userId: string, ids: string[]): Promise<BulkIdentifiersResult>;
|
|
549
|
+
/** Change which identifier is marked as primary (embedded in JWT on next login/refresh). */
|
|
550
|
+
changePrimaryIdentifier(userId: string, identifierId: string): Promise<ChangePrimaryResult>;
|
|
444
551
|
}
|
|
445
552
|
interface ClientAuthClient<TRole extends string = string> extends AuthClient<TRole> {
|
|
446
553
|
}
|
|
@@ -502,6 +609,17 @@ interface CreateServerOptions<TRole extends string = string> {
|
|
|
502
609
|
* When set, `auth.idempotencyMiddleware()` uses Redis as the cache backend.
|
|
503
610
|
*/
|
|
504
611
|
redisUrl?: string;
|
|
612
|
+
/**
|
|
613
|
+
* Structured logger for all auth events.
|
|
614
|
+
* Accepts any object with `info`, `warn`, `error` methods (pino, winston, console).
|
|
615
|
+
* When omitted, sentri produces no log output.
|
|
616
|
+
*/
|
|
617
|
+
logger?: SentriLogger;
|
|
618
|
+
/**
|
|
619
|
+
* Service name added to every log entry as `service`.
|
|
620
|
+
* Defaults to `'sentri'`.
|
|
621
|
+
*/
|
|
622
|
+
loggerService?: string;
|
|
505
623
|
}
|
|
506
624
|
/**
|
|
507
625
|
* Create a Sentri auth server for PostgreSQL.
|
|
@@ -549,4 +667,4 @@ declare global {
|
|
|
549
667
|
}
|
|
550
668
|
}
|
|
551
669
|
|
|
552
|
-
export { type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type
|
|
670
|
+
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 SentriLogger, 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 Fe from'bcrypt';import W 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 He=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}),c=class extends Error{code;statusCode;constructor(r,t,s){super(t),this.name="SentriError",this.code=r,this.statusCode=s??He[r]??500;}};async function G(e,r=12){return Fe.hash(e,r)}async function Z(e,r){return Fe.compare(e,r)}var je=new Map,qe=new Map,Nr=3600*1e3;function Me(e){let r=je.get(e);if(!r){let t=createPrivateKey(e),s=createPublicKey(t),n=s.export({format:"jwk"}),i=Buffer.from(e).slice(0,8).toString("base64url"),u={...n,use:"sig",kid:i};r={kid:i,publicKey:s,jwk:u},je.set(e,r);}return r}function Ve(e){let{jwk:r}=Me(e);return {keys:[r]}}function Be(e){return Me(e).publicKey}async function Xe(e){let r=Date.now(),t=qe.get(e);if(t&&r-t.fetchedAt<Nr)return t.publicKey;let s=await fetch(e);if(!s.ok)throw new c("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${s.status}`);let n=await s.json();if(!n.keys||n.keys.length===0)throw new c("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let i=n.keys[0],u=createPublicKey({key:i,format:"jwk"});return qe.set(e,{publicKey:u,fetchedAt:r}),u}var Je=new WeakMap,ze=32,Ge=10,Ze=31;function Ye(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new c("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new c("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<ze)throw new c("CONFIGURATION_ERROR",`secret must be at least ${ze} characters for HMAC algorithms`);let s=e.saltRounds??12;if(!Number.isInteger(s)||s<Ge||s>Ze)throw new c("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Ge} and ${Ze}`);if(!e.validRoles||e.validRoles.length===0)throw new c("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new c("CONFIGURATION_ERROR","dialect is required in server mode")}function T(e){let r=Je.get(e);if(r)return r;let t={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 Je.set(e,t),t}var Or=/^(\d+)([smhdw])$/,Ur={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},We=new Map;function j(e){if(typeof e=="number")return e*1e3;let r=We.get(e);if(r!==void 0)return r;let t=Or.exec(e);if(!t?.[1]||!t?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let s=Ur[t[2]];if(s===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let n=parseInt(t[1],10)*s;return We.set(e,n),n}var Qe=new Map,er=new Map,rr=new Map;function tr(e){return e.startsWith("RS")||e.startsWith("PS")}function sr(e){let r=Qe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Qe.set(e,r)),r}function Lr(e){let r=er.get(e);return r||(r=createPrivateKey(e),er.set(e,r)),r}function ir(e){let r=T(e);if(tr(r.algorithm)){let n=Lr(e.secret);return {accessKey:n,refreshKey:n}}let{access:t,refresh:s}=sr(e.secret);return {accessKey:t,refreshKey:s}}function nr(e,r){let t=T(e);if(tr(t.algorithm))return Be(e.secret);let{access:s,refresh:n}=sr(e.secret);return r==="access"?s:n}function or(e,r,t,s){let n=`${t}:${s}`,i=rr.get(n);return i||(i={expiresIn:t,algorithm:s},rr.set(n,i)),W.sign(e,r,i)}function ar(e,r,t){try{let s=W.verify(e,r,{algorithms:[t]});if(typeof s=="string"||s===null)throw new c("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof c?s:s instanceof W.TokenExpiredError?new c("TOKEN_EXPIRED","Token has expired"):new c("TOKEN_INVALID","Token is invalid or malformed")}}function Y(e,r){let t=T(r),{accessKey:s}=ir(r);return or(e,s,t.accessExpiresIn,t.algorithm)}function Q(e,r){let t=T(r),{refreshKey:s}=ir(r);return or({sessionId:e},s,t.refreshExpiresIn,t.algorithm)}function de(e,r){let t=T(r),s=nr(r,"access");return ar(e,s,t.algorithm)}function ee(e,r){let t=T(r),s=nr(r,"refresh");return ar(e,s,t.algorithm)}function ur(e,r){try{let t=W.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof t=="string"||t===null)throw new c("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof c?t:t instanceof W.TokenExpiredError?new c("TOKEN_EXPIRED","Token has expired"):new c("TOKEN_INVALID","Token is invalid or malformed")}}function F(e){return T(e).cookieName}function q(e,r){if(!e)return;let t=`${r}=`,s=0;for(;s<e.length;){for(;s<e.length&&e[s]===" ";)s++;let n=e.indexOf(";",s),i=n===-1?e.length:n;if(e.startsWith(t,s))return e.slice(s+t.length,i);s=i+1;}}function re(e,r,t){let s=t.cookie??{},n=T(t),i=j(n.refreshExpiresIn);e.cookie(F(t),r,{httpOnly:s.httpOnly??true,secure:s.secure??false,sameSite:s.sameSite??"strict",path:s.path??"/",maxAge:i});}function ce(e,r){let t=r.cookie??{};e.clearCookie(F(r),{path:t.path??"/"});}function ke(e){return T(e).accessCookieName}function te(e,r,t){if(!t.accessCookie)return;let s=t.accessCookie,n=T(t),i=j(n.accessExpiresIn);e.cookie(ke(t),r,{httpOnly:false,secure:s.secure??false,sameSite:s.sameSite??"strict",path:s.path??"/",maxAge:i});}function Se(e,r){if(!r.accessCookie)return;let t=r.accessCookie;e.clearCookie(ke(r),{path:t.path??"/"});}function se(e,r){let t=e.headers.authorization;return t?.startsWith("Bearer ")?t.slice(7):q(e.headers.cookie,ke(r))}var dr=new Map;function k(e){let r=dr.get(e);return r||(r=new Kysely({dialect:e}),dr.set(e,r)),r}function Ee(e){try{return JSON.parse(e)}catch{return []}}function Fr(e){return JSON.stringify(e)}function lr(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 ie(e,r){let t=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",s=>s.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 t?{id:t.id,identifier:t.primary_value,identifierType:t.primary_type,passwordHash:t.password_hash,roles:Ee(t.roles)}:null}async function le(e,r){let t=await e.selectFrom("sentri_users as u").innerJoin("sentri_identifiers as i",s=>s.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 t?{id:t.id,identifier:t.primary_value,identifierType:t.primary_type,passwordHash:t.password_hash,roles:Ee(t.roles)}:null}async function fr(e,r,t){let s=t.map(n=>({id:randomUUID(),user_id:r,type:n.type,value:n.value,is_primary:n.isPrimary?1:0}));return await e.insertInto("sentri_identifiers").values(s).execute(),s.map(n=>({id:n.id,userId:n.user_id,type:n.type,value:n.value,isPrimary:n.is_primary===1,createdAt:new Date}))}async function $(e,r){return (await e.selectFrom("sentri_identifiers").selectAll().where("user_id","=",r).orderBy("created_at","asc").execute()).map(lr)}async function fe(e,r,t){let s=await e.selectFrom("sentri_identifiers").selectAll().where("id","=",r).where("user_id","=",t).executeTakeFirst();return s?lr(s):null}async function pr(e,r){let t=await e.selectFrom("sentri_identifiers").select(s=>s.fn.countAll().as("count")).where("user_id","=",r).executeTakeFirst();return Number(t?.count??0)}async function gr(e,r,t,s){await e.updateTable("sentri_identifiers").set({type:s.type,value:s.value}).where("id","=",r).where("user_id","=",t).execute();}async function mr(e,r,t){await e.deleteFrom("sentri_identifiers").where("user_id","=",r).where("id","in",t).execute();}async function hr(e,r,t){await e.transaction().execute(async s=>{await s.updateTable("sentri_identifiers").set({is_primary:0}).where("user_id","=",r).execute(),await s.updateTable("sentri_identifiers").set({is_primary:1}).where("id","=",t).where("user_id","=",r).execute();});}async function yr(e,r,t){await e.updateTable("sentri_users").set({password_hash:t}).where("id","=",r).execute();}async function Rr(e,r,t){await e.updateTable("sentri_users").set({roles:Fr(t)}).where("id","=",r).execute();}async function be(e,r){let t=randomUUID();return await e.insertInto("sentri_sessions").values({id:t,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:t}}async function wr(e,r){let t=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").innerJoin("sentri_identifiers as i",s=>s.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 t?{id:t.session_id,userId:t.user_id,expiresAt:new Date(t.expires_at),createdAt:new Date(t.session_created_at),user:{id:t.user_id_col,identifier:t.primary_identifier,identifierType:t.primary_identifier_type,passwordHash:t.password_hash,roles:Ee(t.roles)}}:null}async function pe(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function xe(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function ne(e,r){let t=T(r),s=k(r.dialect),n=e.roles??[],i=n.filter(v=>!t.validRolesSet.has(v));if(i.length>0)return {success:false,error:new c("INVALID_ROLE",`Invalid roles: ${i.join(", ")}`)};if(!e.identifiers||e.identifiers.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one identifier is required")};let u=e.identifiers.map(v=>({type:v.type.trim(),value:v.value.trim()}));if(new Set(u.map(v=>v.value)).size!==u.length)return {success:false,error:new c("VALIDATION_ERROR","Duplicate identifier values in request")};for(let v of u)if(await ie(s,v.value))return {success:false,error:new c("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${v.value}`)};let d=await G(e.password,t.saltRounds),{userId:g,identifierRows:h}=await s.transaction().execute(async v=>{let P=randomUUID();await v.insertInto("sentri_users").values({id:P,password_hash:d,roles:JSON.stringify(n)}).execute();let K=u.map((L,Ce)=>({id:randomUUID(),user_id:P,type:L.type,value:L.value,is_primary:Ce===0?1:0}));return await v.insertInto("sentri_identifiers").values(K).execute(),{userId:P,identifierRows:K}}),x=h.map(v=>({id:v.id,type:v.type,value:v.value,isPrimary:v.is_primary===1})),S=x[0];return {success:true,user:{id:g,identifier:S.value,identifierType:S.type,roles:n,identifiers:x}}}async function ge(e,r){let t=T(r),s=k(r.dialect),n=await ie(s,e.identifier.trim());if(!n)return {success:false,error:new c("INVALID_CREDENTIALS","Invalid credentials")};if(!await Z(e.password,n.passwordHash))return {success:false,error:new c("INVALID_CREDENTIALS","Invalid credentials")};let u=new Date(Date.now()+j(t.refreshExpiresIn)),o=await be(s,{userId:n.id,expiresAt:u}),d={id:n.id,identifier:n.identifier,identifierType:n.identifierType,roles:n.roles},g=Y({id:n.id,identifier:n.identifier,identifierType:n.identifierType,roles:n.roles,sessionId:o.id},r),h=Q(o.id,r);return {success:true,accessToken:g,refreshToken:h,user:d}}async function M(e,r){let t=T(r),s=k(r.dialect),n;try{({sessionId:n}=ee(e,r));}catch(x){return x instanceof c?{success:false,error:x}:{success:false,error:new c("TOKEN_INVALID","Invalid refresh token")}}let i=await wr(s,n);if(!i)return {success:false,error:new c("UNAUTHORIZED","Session not found or revoked")};if(i.expiresAt.getTime()<Date.now())return await pe(s,n),{success:false,error:new c("TOKEN_EXPIRED","Session has expired")};await pe(s,n);let u=new Date(Date.now()+j(t.refreshExpiresIn)),o=await be(s,{userId:i.userId,expiresAt:u}),d={id:i.user.id,identifier:i.user.identifier,identifierType:i.user.identifierType,roles:i.user.roles},g=Y({...d,sessionId:o.id},r),h=Q(o.id,r);return {success:true,accessToken:g,refreshToken:h,user:d}}async function me(e,r){let t=k(r.dialect),s;try{({sessionId:s}=ee(e,r));}catch{return}await pe(t,s);}async function he(e,r){let t=k(r.dialect);await xe(t,e);}async function Ar(e,r){let t=k(r.dialect),s=await le(t,e);if(!s)return {success:false,error:new c("USER_NOT_FOUND","User not found")};let n=await $(t,e);return {success:true,user:{id:s.id,identifier:s.identifier,identifierType:s.identifierType,roles:s.roles,identifiers:n.map(i=>({id:i.id,type:i.type,value:i.value,isPrimary:i.isPrimary}))}}}async function ye(e,r,t,s){let n=T(s),i=k(s.dialect),u=await le(i,e);if(!u)return {success:false,error:new c("USER_NOT_FOUND","User not found")};if(!await Z(r,u.passwordHash))return {success:false,error:new c("INVALID_CREDENTIALS","Invalid credentials")};let d=await G(t,n.saltRounds);return await yr(i,e,d),await xe(i,e),{success:true}}async function Re(e,r,t){let s=T(t),n=k(t.dialect),i=r.filter(g=>!s.validRolesSet.has(g));if(i.length>0)return {success:false,error:new c("INVALID_ROLE",`Invalid roles: ${i.join(", ")}`)};let u=await le(n,e);if(!u)return {success:false,error:new c("USER_NOT_FOUND","User not found")};let o=new Set(u.roles);for(let g of r)o.add(g);let d=Array.from(o);return await Rr(n,e,d),{success:true,user:{id:u.id,identifier:u.identifier,identifierType:u.identifierType,roles:d}}}async function we(e,r,t){let s=k(t.dialect);if(r.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one identifier is required")};let n=r.map(d=>({type:d.type.trim(),value:d.value.trim()}));if(new Set(n.map(d=>d.value)).size!==n.length)return {success:false,error:new c("VALIDATION_ERROR","Duplicate identifier values in request")};for(let d of n)if(await ie(s,d.value))return {success:false,error:new c("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${d.value}`)};await fr(s,e,n.map(d=>({...d,isPrimary:false})));return {success:true,identifiers:(await $(s,e)).map(d=>({id:d.id,type:d.type,value:d.value,isPrimary:d.isPrimary}))}}async function Ie(e,r,t){let s=k(t.dialect);if(r.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one update is required")};let n=r.map(o=>({id:o.id,type:o.type.trim(),value:o.value.trim()}));if(new Set(n.map(o=>o.value)).size!==n.length)return {success:false,error:new c("VALIDATION_ERROR","Duplicate identifier values in request")};for(let o of n){let d=await fe(s,o.id,e);if(!d)return {success:false,error:new c("IDENTIFIER_NOT_FOUND",`Identifier not found: ${o.id}`)};if(d.value!==o.value&&await ie(s,o.value))return {success:false,error:new c("IDENTIFIER_ALREADY_EXISTS",`Identifier already taken: ${o.value}`)}}for(let o of n)await gr(s,o.id,e,{type:o.type,value:o.value});return {success:true,identifiers:(await $(s,e)).map(o=>({id:o.id,type:o.type,value:o.value,isPrimary:o.isPrimary}))}}async function Ae(e,r,t){let s=k(t.dialect);if(r.length===0)return {success:false,error:new c("VALIDATION_ERROR","At least one ID is required")};for(let u of r)if(!await fe(s,u,e))return {success:false,error:new c("IDENTIFIER_NOT_FOUND",`Identifier not found: ${u}`)};return await pr(s,e)-r.length<1?{success:false,error:new c("VALIDATION_ERROR","Cannot delete all identifiers \u2014 at least one must remain")}:(await mr(s,e,r),{success:true,identifiers:(await $(s,e)).map(u=>({id:u.id,type:u.type,value:u.value,isPrimary:u.isPrimary}))})}async function ve(e,r,t){let s=k(t.dialect);return await fe(s,r,e)?(await hr(s,e,r),{success:true,identifiers:(await $(s,e)).map(u=>({id:u.id,type:u.type,value:u.value,isPrimary:u.isPrimary}))}):{success:false,error:new c("IDENTIFIER_NOT_FOUND",`Identifier not found: ${r}`)}}var O={info:()=>{},warn:()=>{},error:()=>{}};function m(e,r,t){return {service:e,event:r,...t}}function E(e){let r=e.logger??O,t=e.loggerService??"sentri";return e.mode==="client"?jr(e.keyUri,r,t):qr(e,r,t)}function jr(e,r,t){return async(s,n,i)=>{let u=s.headers.authorization,o=u?.startsWith("Bearer ")?u.slice(7):void 0,d=s.requestId;if(!o)return r.warn(m(t,"auth.protect.failure",{mode:"client",errorCode:"UNAUTHORIZED",...d!==void 0&&{requestId:d}})),i(new c("UNAUTHORIZED","Missing or malformed Authorization header"));try{let g=await Xe(e),h=ur(o,g);s.user={id:h.id,identifier:h.identifier,identifierType:h.identifierType,roles:h.roles},r.info(m(t,"auth.protect.success",{mode:"client",userId:h.id,...d!==void 0&&{requestId:d}})),i();}catch(g){let h=g instanceof c?g.code:"TOKEN_INVALID";r.warn(m(t,"auth.protect.failure",{mode:"client",errorCode:h,...d!==void 0&&{requestId:d}})),i(g);}}}function qr(e,r,t){return async(s,n,i)=>{let u=se(s,e),o=s.requestId;if(!u)return r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:"UNAUTHORIZED",...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Missing or malformed Authorization header"));try{let d=de(u,e);if(e.isTokenRevoked&&await e.isTokenRevoked(d.sessionId))return r.warn(m(t,"auth.protect.token_revoked",{mode:"server",userId:d.id,...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Token has been revoked"));s.user={id:d.id,identifier:d.identifier,identifierType:d.identifierType,roles:d.roles},r.info(m(t,"auth.protect.success",{mode:"server",userId:d.id,...o!==void 0&&{requestId:o}})),i();}catch(d){if(d instanceof c&&d.code==="TOKEN_EXPIRED"){let g=q(s.headers.cookie,F(e));if(!g)return r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:"TOKEN_EXPIRED",...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Token expired. Please login again."));try{let h=await M(g,e);if(!h.success)return r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:h.error.code,...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Session expired. Please login again."));re(n,h.refreshToken,e),te(n,h.accessToken,e),n.setHeader("X-New-Access-Token",h.accessToken),s.user=h.user,r.info(m(t,"auth.protect.auto_refresh",{mode:"server",userId:h.user.id,...o!==void 0&&{requestId:o}})),i();}catch{r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:"TOKEN_EXPIRED",...o!==void 0&&{requestId:o}})),i(new c("UNAUTHORIZED","Session expired. Please login again."));}}else {let g=d instanceof c?d.code:"TOKEN_INVALID";r.warn(m(t,"auth.protect.failure",{mode:"server",errorCode:g,...o!==void 0&&{requestId:o}})),i(d);}}}}function vr(e,r,t){let s=`Requires one of roles: ${e.join(", ")}`;return (n,i,u)=>{let o=n.requestId;if(!n.user)return r.warn(m(t,"auth.authorize.unauthenticated",{requiredRoles:e,...o!==void 0&&{requestId:o}})),u(new c("UNAUTHORIZED","Not authenticated"));let d=n.user.roles;if(!e.some(g=>d.includes(g)))return r.warn(m(t,"auth.authorize.denied",{userId:n.user.id,userRoles:[...d],requiredRoles:e,...o!==void 0&&{requestId:o}})),u(new c("FORBIDDEN",s));r.info(m(t,"auth.authorize.passed",{userId:n.user.id,userRoles:[...d],requiredRoles:e,...o!==void 0&&{requestId:o}})),u();}}function oe(e,r){return function(...s){return vr(s,e,r)}}function De(...e){return vr(e,O,"sentri")}var $r=new c("FORBIDDEN","You do not have permission to perform this action");function _r(e,r,t){return async(s,n,i)=>{let u=s.requestId;if(!s.user)return r.warn(m(t,"auth.permit.unauthenticated",{...u!==void 0&&{requestId:u}})),i(new c("UNAUTHORIZED","Not authenticated"));let o=s.user.id;if(e.roles&&e.roles.length>0){let d=s.user.roles;if(e.roles.some(g=>d.includes(g)))return r.info(m(t,"auth.permit.role_bypass",{userId:o,bypassedByRole:true,...u!==void 0&&{requestId:u}})),i()}try{let d=e.check(s);(d instanceof Promise?await d:d)?(r.info(m(t,"auth.permit.passed",{userId:o,...u!==void 0&&{requestId:u}})),i()):(r.warn(m(t,"auth.permit.denied",{userId:o,...u!==void 0&&{requestId:u}})),i($r));}catch(d){i(d);}}}function ae(e,r){return function(s){return _r(typeof s=="function"?{check:s}:s,e,r)}}function Pe(e){return _r(typeof e=="function"?{check:e}:e,O,"sentri")}function V(e){return (r,t,s,n)=>{if(r instanceof c){s.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),s.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var _e=8,B=72,Te=255,Tr=100,X=50;function w(e){return new c("VALIDATION_ERROR",e)}function b(e,r,t,s){e.status(r).json({error:false,statusCode:r,message:t,data:s});}function N(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function U(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new c("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function Vr(e,r){if(!r.apiKey)return;let t=e.headers["x-api-key"];if(typeof t!="string"||t!==r.apiKey)throw new c("UNAUTHORIZED","Invalid or missing API key")}function Ne(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function Br(e){return e.startsWith("RS")||e.startsWith("PS")}function Oe(e,r){if(typeof e!="object"||e===null||Array.isArray(e))throw w(`identifiers[${r}] must be an object`);let t=e;if(typeof t.type!="string"||t.type.trim().length===0)throw w(`identifiers[${r}].type is required and must be a non-empty string`);if(t.type.length>Tr)throw w(`identifiers[${r}].type must not exceed ${Tr} characters`);if(typeof t.value!="string"||t.value.trim().length===0)throw w(`identifiers[${r}].value is required and must be a non-empty string`);if(t.value.length>Te)throw w(`identifiers[${r}].value must not exceed ${Te} characters`);return {type:t.type,value:t.value}}function C(e){return e.requestId!==void 0?{requestId:e.requestId}:{}}function Cr(e){let r=Router(),t=e,s=T(t),n=e.logger??O,i=e.loggerService??"sentri",u=oe(n,i),o=ae(n,i),d=e.router?.register??(a=>ne(a,t)),g=e.router?.login??(a=>ge(a,t)),h=e.router?.refresh??(a=>M(a,t)),x=e.router?.logout??(a=>a!==void 0?me(a,t):Promise.resolve()),S=e.router?.logoutAll??(a=>he(a,t)),v=e.router?.assignRoles??((a,l)=>Re(a,l,t)),P=e.router?.changePassword??((a,l,_)=>ye(a,l,_,t)),K=e.router?.bulkCreateIdentifiers??((a,l)=>we(a,l,t)),L=e.router?.bulkUpdateIdentifiers??((a,l)=>Ie(a,l,t)),Ce=e.router?.bulkDeleteIdentifiers??((a,l)=>Ae(a,l,t)),xr=e.router?.changePrimaryIdentifier??((a,l)=>ve(a,l,t));Br(s.algorithm)&&r.get("/keys",(a,l)=>{l.setHeader("Cache-Control","public, max-age=3600"),l.json(Ve(e.secret));}),r.post("/register",async(a,l,_)=>{let I=Date.now();try{Vr(a,e);let p=U(a.body),{identifiers:f,password:y,roles:R}=p;if(!Array.isArray(f)||f.length===0)throw w("identifiers is required and must be a non-empty array");if(f.length>X)throw w(`identifiers must not exceed ${X} entries`);let A=f.map((z,Dr)=>Oe(z,Dr));if(typeof y!="string"||y.length<_e)throw w(`password is required and must be at least ${_e} characters`);if(y.length>B)throw w(`password must not exceed ${B} characters`);if(R!==void 0&&!Array.isArray(R))throw w("roles must be an array of strings when provided");if(Array.isArray(R)&&!R.every(z=>typeof z=="string"))throw w("each role must be a string");let D=Array.isArray(R)?R:void 0,H=await d(D!==void 0?{identifiers:A,password:y,roles:D}:{identifiers:A,password:y});if(!H.success){n.warn(m(i,"auth.register.failure",{errorCode:H.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,H.error);return}n.info(m(i,"auth.register.success",{userId:H.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,201,"User registered successfully",{user:H.user});}catch(p){_(p);}}),r.post("/login",async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{identifier:f,password:y}=p;if(typeof f!="string"||f.trim().length===0)throw w("identifier is required and must be a non-empty string");if(f.length>Te)throw w(`identifier must not exceed ${Te} characters`);if(typeof y!="string"||y.length===0)throw w("password is required");if(y.length>B)throw w(`password must not exceed ${B} characters`);let R=f.trim(),A=await g({identifier:R,password:y});if(!A.success){Ne(()=>e.hooks?.onFailedLogin?.(R,A.error)),n.warn(m(i,"auth.login.failure",{errorCode:A.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,A.error);return}Ne(()=>e.hooks?.onLogin?.(A.user)),re(l,A.refreshToken,e),te(l,A.accessToken,e),n.info(m(i,"auth.login.success",{userId:A.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Login successful",{accessToken:A.accessToken,user:A.user});}catch(p){_(p);}}),r.post("/refresh",async(a,l,_)=>{let I=Date.now();try{let p=q(a.headers.cookie,F(e));if(!p)throw new c("UNAUTHORIZED","Refresh token cookie is missing");let f=await h(p);if(!f.success){ce(l,e),n.warn(m(i,"auth.refresh.failure",{errorCode:f.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,f.error);return}re(l,f.refreshToken,e),te(l,f.accessToken,e),n.info(m(i,"auth.refresh.success",{userId:f.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Token refreshed",{accessToken:f.accessToken});}catch(p){_(p);}}),r.post("/logout",async(a,l,_)=>{let I=Date.now();try{let p=q(a.headers.cookie,F(e));await x(p),ce(l,e),Se(l,e),n.info(m(i,"auth.logout",{duration_ms:Date.now()-I,...C(a)})),b(l,200,"Logged out",null);}catch(p){_(p);}}),r.post("/logout-all",E(e),async(a,l,_)=>{let I=Date.now();try{let p=a.user.id;await S(p),Ne(()=>e.hooks?.onLogout?.(p)),ce(l,e),Se(l,e),n.info(m(i,"auth.logout_all",{userId:p,duration_ms:Date.now()-I,...C(a)})),b(l,200,"All sessions revoked",null);}catch(p){_(p);}}),r.get("/me",E(e),(a,l)=>{b(l,200,"OK",a.user);});let J=o(a=>!!a.user);return r.post("/me/identifiers",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{identifiers:f}=p;if(!Array.isArray(f)||f.length===0)throw w("identifiers is required and must be a non-empty array");if(f.length>X)throw w(`identifiers must not exceed ${X} entries`);let y=f.map((A,D)=>Oe(A,D)),R=await K(a.user.id,y);if(!R.success){n.warn(m(i,"auth.identifiers.create_failure",{userId:a.user.id,errorCode:R.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,R.error);return}n.info(m(i,"auth.identifiers.created",{userId:a.user.id,count:R.identifiers.length,duration_ms:Date.now()-I,...C(a)})),b(l,201,"Identifiers added successfully",{identifiers:R.identifiers});}catch(p){_(p);}}),r.put("/me/identifiers",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{identifiers:f}=p;if(!Array.isArray(f)||f.length===0)throw w("identifiers is required and must be a non-empty array");if(f.length>X)throw w(`identifiers must not exceed ${X} entries`);let y=f.map((A,D)=>{if(typeof A!="object"||A===null||Array.isArray(A))throw w(`identifiers[${D}] must be an object`);let ue=A;if(typeof ue.id!="string"||ue.id.trim().length===0)throw w(`identifiers[${D}].id is required and must be a non-empty string`);let{type:H,value:z}=Oe(A,D);return {id:ue.id,type:H,value:z}}),R=await L(a.user.id,y);if(!R.success){n.warn(m(i,"auth.identifiers.update_failure",{userId:a.user.id,errorCode:R.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,R.error);return}n.info(m(i,"auth.identifiers.updated",{userId:a.user.id,count:R.identifiers.length,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Identifiers updated successfully",{identifiers:R.identifiers});}catch(p){_(p);}}),r.delete("/me/identifiers",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{ids:f}=p;if(!Array.isArray(f)||f.length===0)throw w("ids is required and must be a non-empty array of strings");if(!f.every(R=>typeof R=="string"))throw w("each id must be a string");let y=await Ce(a.user.id,f);if(!y.success){n.warn(m(i,"auth.identifiers.delete_failure",{userId:a.user.id,errorCode:y.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,y.error);return}n.info(m(i,"auth.identifiers.deleted",{userId:a.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Identifiers deleted successfully",{identifiers:y.identifiers});}catch(p){_(p);}}),r.patch("/me/identifiers/primary",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{id:f}=p;if(typeof f!="string"||f.trim().length===0)throw w("id is required and must be a non-empty string");let y=await xr(a.user.id,f.trim());if(!y.success){n.warn(m(i,"auth.identifiers.primary_change_failure",{userId:a.user.id,errorCode:y.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,y.error);return}n.info(m(i,"auth.identifiers.primary_changed",{userId:a.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Primary identifier updated successfully",{identifiers:y.identifiers});}catch(p){_(p);}}),r.patch("/me/password",E(e),J,async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{currentPassword:f,newPassword:y}=p;if(typeof f!="string"||f.length===0)throw w("currentPassword is required");if(typeof y!="string"||y.length<_e)throw w(`newPassword must be at least ${_e} characters`);if(y.length>B)throw w(`newPassword must not exceed ${B} characters`);if(f===y)throw w("newPassword must be different from currentPassword");let R=await P(a.user.id,f,y);if(!R.success){n.warn(m(i,"auth.password.change_failure",{userId:a.user.id,errorCode:R.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,R.error);return}n.info(m(i,"auth.password.changed",{userId:a.user.id,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Password updated successfully. All sessions have been revoked.",null);}catch(p){_(p);}}),r.post("/users/:userId/roles",E(e),u("admin"),async(a,l,_)=>{let I=Date.now();try{let p=U(a.body),{roles:f}=p,y=a.params.userId,R=typeof y=="string"?y:void 0;if(!R)throw w("userId is required");if(!Array.isArray(f)||f.length===0)throw w("roles must be a non-empty array of strings");if(!f.every(D=>typeof D=="string"))throw w("each role must be a string");let A=await v(R,f);if(!A.success){n.warn(m(i,"auth.roles.assign_failure",{targetUserId:R,errorCode:A.error.code,duration_ms:Date.now()-I,...C(a)})),N(l,A.error);return}n.info(m(i,"auth.roles.assigned",{targetUserId:R,roles:f,duration_ms:Date.now()-I,...C(a)})),b(l,200,"Roles assigned successfully",{user:A.user});}catch(p){_(p);}}),r.use(V()),r}var kr=new Map;function Sr(e){let r=kr.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),kr.set(e,r)),r}function Ue(e){let r=e?.ttl??3e5,t=(e?.header??"X-Idempotency-Key").toLowerCase(),s=new Set((e?.methods??["POST","PUT","PATCH"]).map(i=>i.toUpperCase())),n=e?.redisUrl;return n?Jr(n,r,t,s):zr(r,t,s,e?.maxSize??1e4)}function Jr(e,r,t,s){let n=Sr(e),i="sentri:idempotency:";return async(u,o,d)=>{let g=u.headers[t];if(!g||typeof g!="string"||!s.has(u.method))return d();u.requestId=g,o.setHeader("X-Request-Id",g);let h=await n.get(`${i}${g}`);if(h){let S=JSON.parse(h);return o.setHeader("X-Idempotent-Replayed","true"),o.status(S.statusCode).json(S.body)}let x=o.json.bind(o);o.json=function(v){if(o.statusCode>=200&&o.statusCode<300){let P={statusCode:o.statusCode,body:v,expiresAt:Date.now()+r};n.set(`${i}${g}`,JSON.stringify(P),"PX",r).catch(()=>{});}return x(v)},d();}}function zr(e,r,t,s){let n=Math.max(e,5e3),i=new Map,u=setInterval(()=>{let o=Date.now();for(let[d,g]of i)g.expiresAt<=o&&i.delete(d);},n);return typeof u=="object"&&u!==null&&"unref"in u&&u.unref(),(o,d,g)=>{let h=o.headers[r];if(!h||typeof h!="string"||!t.has(o.method))return g();o.requestId=h,d.setHeader("X-Request-Id",h);let x=Date.now(),S=i.get(h);if(S&&S.expiresAt>x)return d.setHeader("X-Idempotent-Replayed","true"),d.status(S.statusCode).json(S.body);let v=d.json.bind(d);d.json=function(K){if(d.statusCode>=200&&d.statusCode<300){if(i.size>=s){let L=i.keys().next().value;L!==void 0&&i.delete(L);}i.set(h,{statusCode:d.statusCode,body:K,expiresAt:Date.now()+e});}return v(K)},g();}}async function Er(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 Le(e){if(Ye(e),e.mode==="client"){let i=e.logger?oe(e.logger,e.loggerService??"sentri"):De,u=e.logger?ae(e.logger,e.loggerService??"sentri"):Pe;return {protect:()=>E(e),authorize:(...o)=>i(...o),permit:o=>u(o),errorHandler:o=>V(o)}}let r=e,t=T(r),s=r.logger?oe(r.logger,r.loggerService??"sentri"):De,n=r.logger?ae(r.logger,r.loggerService??"sentri"):Pe;return {protect:()=>E(r),authorize:(...i)=>s(...i),permit:i=>n(i),hashPassword:i=>G(i,t.saltRounds),verifyPassword:(i,u)=>Z(i,u),signAccessToken:i=>Y(i,r),signRefreshToken:i=>Q(i,r),verifyAccessToken:i=>de(i,r),verifyRefreshToken:i=>ee(i,r),getCurrentAccessToken:i=>se(i,r),router:()=>Cr(r),migrate:()=>Er(k(r.dialect)),errorHandler:i=>V(i),idempotencyMiddleware:i=>Ue({...i,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:i=>ne(i,r),login:i=>ge(i,r),refresh:i=>M(i,r),logout:i=>me(i,r),logoutAll:i=>he(i,r),getUser:i=>Ar(i,r),changePassword:(i,u,o)=>ye(i,u,o,r),assignRoles:(i,u)=>Re(i,u,r),bulkCreateIdentifiers:(i,u)=>we(i,u,r),bulkUpdateIdentifiers:(i,u)=>Ie(i,u,r),bulkDeleteIdentifiers:(i,u)=>Ae(i,u,r),changePrimaryIdentifier:(i,u)=>ve(i,u,r)}}function br(e){return new PostgresDialect({pool:new Pool(e)})}function Yr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),t={mode:"server",dialect:br(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},...e.logger!==void 0&&{logger:e.logger},...e.loggerService!==void 0&&{loggerService:e.loggerService}};return Le(t)}export{He as SENTRI_ERROR_STATUS,c as SentriError,Le as createAuth,Yr as createAuthServer,V as createErrorHandler,Ue as createIdempotencyMiddleware,se as getCurrentAccessToken,ne as register};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentri",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Personal auth/authorization library for Express + Postgres",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -54,10 +54,13 @@
|
|
|
54
54
|
"bcrypt": "^6.0.0",
|
|
55
55
|
"ioredis": "^5.11.1",
|
|
56
56
|
"jsonwebtoken": "^9.0.3",
|
|
57
|
-
"kysely": "^0.
|
|
57
|
+
"kysely": "^0.29.2",
|
|
58
58
|
"pg": "^8.13.1"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"express": ">=4.0.0"
|
|
62
|
+
},
|
|
63
|
+
"overrides": {
|
|
64
|
+
"esbuild": "0.28.1"
|
|
62
65
|
}
|
|
63
66
|
}
|