sentri 1.1.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +325 -181
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +10 -103
- package/dist/index.d.ts +1046 -11
- package/dist/index.js +1 -5
- package/package.json +13 -6
- package/templates/drizzle/auth.ts +47 -4
- package/templates/prisma/auth.ts +47 -4
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.d.ts +0 -160
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -45
- package/dist/client.js.map +0 -1
- package/dist/errors/AuthError.d.ts +0 -99
- package/dist/errors/AuthError.d.ts.map +0 -1
- package/dist/errors/AuthError.js +0 -97
- package/dist/errors/AuthError.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/libs/config.d.ts +0 -62
- package/dist/libs/config.d.ts.map +0 -1
- package/dist/libs/config.js +0 -97
- package/dist/libs/config.js.map +0 -1
- package/dist/libs/hash.d.ts +0 -17
- package/dist/libs/hash.d.ts.map +0 -1
- package/dist/libs/hash.js +0 -22
- package/dist/libs/hash.js.map +0 -1
- package/dist/libs/token.d.ts +0 -46
- package/dist/libs/token.d.ts.map +0 -1
- package/dist/libs/token.js +0 -118
- package/dist/libs/token.js.map +0 -1
- package/dist/middleware/authorize.d.ts +0 -18
- package/dist/middleware/authorize.d.ts.map +0 -1
- package/dist/middleware/authorize.js +0 -30
- package/dist/middleware/authorize.js.map +0 -1
- package/dist/middleware/errorHandler.d.ts +0 -71
- package/dist/middleware/errorHandler.d.ts.map +0 -1
- package/dist/middleware/errorHandler.js +0 -74
- package/dist/middleware/errorHandler.js.map +0 -1
- package/dist/middleware/permit.d.ts +0 -62
- package/dist/middleware/permit.d.ts.map +0 -1
- package/dist/middleware/permit.js +0 -61
- package/dist/middleware/permit.js.map +0 -1
- package/dist/middleware/protect.d.ts +0 -31
- package/dist/middleware/protect.d.ts.map +0 -1
- package/dist/middleware/protect.js +0 -54
- package/dist/middleware/protect.js.map +0 -1
- package/dist/middleware/router.d.ts +0 -34
- package/dist/middleware/router.d.ts.map +0 -1
- package/dist/middleware/router.js +0 -264
- package/dist/middleware/router.js.map +0 -1
- package/dist/services/auth.d.ts +0 -85
- package/dist/services/auth.d.ts.map +0 -1
- package/dist/services/auth.js +0 -173
- package/dist/services/auth.js.map +0 -1
- package/dist/types/auth.d.ts +0 -450
- package/dist/types/auth.d.ts.map +0 -1
- package/dist/types/auth.js +0 -21
- package/dist/types/auth.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sentri
|
|
2
2
|
|
|
3
|
-
Auth and authorization library for Express + PostgreSQL. Provides JWT
|
|
3
|
+
Auth and authorization library for Express + PostgreSQL. Provides stateless JWT authentication, automatic token refresh, cookie-based token storage, request idempotency, role-based access control, fine-grained permission checks, and a pre-built Express router — all behind a single typed client.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -10,6 +10,13 @@ Auth and authorization library for Express + PostgreSQL. Provides JWT-based auth
|
|
|
10
10
|
- [Quick Start](#quick-start)
|
|
11
11
|
- [CLI](#cli)
|
|
12
12
|
- [Configuration](#configuration)
|
|
13
|
+
- [Token Storage](#token-storage)
|
|
14
|
+
- [Stateless Access Tokens](#stateless-access-tokens)
|
|
15
|
+
- [Automatic Token Refresh](#automatic-token-refresh)
|
|
16
|
+
- [Request Idempotency](#request-idempotency)
|
|
17
|
+
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
18
|
+
- [Immediate Token Revocation](#immediate-token-revocation-istokenrevoked)
|
|
19
|
+
- [apiKey — Restricting Registration](#apikey--restricting-registration)
|
|
13
20
|
- [Custom Route Handlers](#custom-route-handlers)
|
|
14
21
|
- [Adapter Interface](#adapter-interface)
|
|
15
22
|
- [Pre-built Router](#pre-built-router)
|
|
@@ -17,7 +24,7 @@ Auth and authorization library for Express + PostgreSQL. Provides JWT-based auth
|
|
|
17
24
|
- [Programmatic API](#programmatic-api)
|
|
18
25
|
- [Types](#types)
|
|
19
26
|
- [Error Handling](#error-handling)
|
|
20
|
-
- [Migration
|
|
27
|
+
- [Migration Guide](#migration-guide)
|
|
21
28
|
|
|
22
29
|
---
|
|
23
30
|
|
|
@@ -61,9 +68,14 @@ prisma/
|
|
|
61
68
|
```typescript
|
|
62
69
|
import express from 'express';
|
|
63
70
|
import { auth } from './lib/sentri/auth.js';
|
|
71
|
+
import { createIdempotencyMiddleware } from 'sentri';
|
|
64
72
|
|
|
65
73
|
const app = express();
|
|
66
74
|
app.use(express.json());
|
|
75
|
+
|
|
76
|
+
// Optional: make POST/PUT/PATCH operations idempotent
|
|
77
|
+
app.use(createIdempotencyMiddleware());
|
|
78
|
+
|
|
67
79
|
app.use('/auth', auth.router());
|
|
68
80
|
|
|
69
81
|
// ... your routes ...
|
|
@@ -111,23 +123,43 @@ export const auth = createAuth({
|
|
|
111
123
|
adapter: myAdapter, // required — see Adapter Interface
|
|
112
124
|
|
|
113
125
|
// optional
|
|
114
|
-
accessExpiresIn: '
|
|
126
|
+
accessExpiresIn: '5m', // default: '15m' — short is safe: auto-refresh handles it
|
|
115
127
|
refreshExpiresIn: '7d', // default: '7d'
|
|
116
128
|
algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
|
|
117
129
|
saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
|
|
118
130
|
|
|
119
|
-
//
|
|
120
|
-
// When set, only requests with this exact key can create new accounts.
|
|
131
|
+
// API key for POST /register — see apiKey section
|
|
121
132
|
apiKey: process.env.REGISTER_API_KEY, // optional
|
|
122
133
|
|
|
123
|
-
|
|
134
|
+
// Access token cookie — non-httpOnly so browser JS can read it.
|
|
135
|
+
// When set, protect() reads from this cookie when no Authorization header is present.
|
|
136
|
+
accessCookie: {
|
|
137
|
+
secure: process.env.NODE_ENV === 'production',
|
|
138
|
+
// name: 'access_token', // default: 'access_token'
|
|
139
|
+
// sameSite: 'strict', // default: 'strict'
|
|
140
|
+
// path: '/', // default: '/'
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Refresh token cookie — httpOnly (not readable by JS).
|
|
144
|
+
cookie: {
|
|
124
145
|
secure: process.env.NODE_ENV === 'production',
|
|
125
|
-
// name: 'refresh_token',
|
|
126
|
-
// httpOnly: true,
|
|
127
|
-
// sameSite: 'strict',
|
|
128
|
-
// path: '/',
|
|
146
|
+
// name: 'refresh_token', // default: 'refresh_token'
|
|
147
|
+
// httpOnly: true, // default: true
|
|
148
|
+
// sameSite: 'strict', // default: 'strict'
|
|
149
|
+
// path: '/', // default: '/'
|
|
129
150
|
},
|
|
130
151
|
|
|
152
|
+
// Lifecycle hooks — fire-and-forget, rejections are swallowed.
|
|
153
|
+
// hooks: {
|
|
154
|
+
// onLogin: async (user) => auditLog.record('login', user.id),
|
|
155
|
+
// onFailedLogin: (identifier) => rateLimiter.hit(`login:${identifier}`),
|
|
156
|
+
// onLogout: async (userId) => cache.invalidate(userId),
|
|
157
|
+
// },
|
|
158
|
+
|
|
159
|
+
// Optional immediate revocation — called on every protect() invocation.
|
|
160
|
+
// isTokenRevoked: async (sessionId) =>
|
|
161
|
+
// await redis.sismember('revoked_sessions', sessionId),
|
|
162
|
+
|
|
131
163
|
// router: { // optional — replace built-in service logic per route
|
|
132
164
|
// login: async (input) => { ... },
|
|
133
165
|
// register: async (input) => { ... },
|
|
@@ -139,53 +171,234 @@ export const auth = createAuth({
|
|
|
139
171
|
});
|
|
140
172
|
```
|
|
141
173
|
|
|
142
|
-
`accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'
|
|
174
|
+
`accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'5m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Token Storage
|
|
179
|
+
|
|
180
|
+
sentri uses a two-cookie strategy for SPAs:
|
|
181
|
+
|
|
182
|
+
| Cookie | `httpOnly` | Readable by JS | Purpose |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| `access_token` | **false** | ✅ Yes | Carries the JWT for authenticated requests |
|
|
185
|
+
| `refresh_token` | **true** | ❌ No | Used to silently rotate the access token |
|
|
186
|
+
|
|
187
|
+
### Setting up cookie storage
|
|
188
|
+
|
|
189
|
+
Enable both cookies in your config:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
export const auth = createAuth({
|
|
193
|
+
accessExpiresIn: '5m',
|
|
194
|
+
accessCookie: { secure: process.env.NODE_ENV === 'production' },
|
|
195
|
+
cookie: { secure: process.env.NODE_ENV === 'production' },
|
|
196
|
+
// ...
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
After `/login`, the router sets both cookies automatically. `protect()` reads the access token from the cookie when no `Authorization: Bearer` header is present, so your SPA needs zero manual header management.
|
|
201
|
+
|
|
202
|
+
### Reading the access token in the browser
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
// Read from document.cookie
|
|
206
|
+
const token = document.cookie
|
|
207
|
+
.split('; ')
|
|
208
|
+
.find(c => c.startsWith('access_token='))
|
|
209
|
+
?.split('=')[1];
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Reading the token server-side
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { getCurrentAccessToken } from 'sentri';
|
|
216
|
+
|
|
217
|
+
app.get('/debug', (req, res) => {
|
|
218
|
+
// Checks Authorization header first, then access_token cookie
|
|
219
|
+
const token = getCurrentAccessToken(req, config);
|
|
220
|
+
res.json({ hasToken: !!token });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Or via the auth client (pre-bound to config):
|
|
224
|
+
const token = auth.getCurrentAccessToken(req);
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Stateless Access Tokens
|
|
143
230
|
|
|
144
|
-
|
|
231
|
+
Access tokens are validated **stateless** — by JWT signature and `exp` claim only. No database lookup occurs on every protected request, which keeps `protect()` fast regardless of load.
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
Request → protect() verifies JWT signature + expiry → ✓ allowed
|
|
235
|
+
(no DB round-trip per request)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Trade-off:** After logout, an access token remains technically valid until it expires (`accessExpiresIn`). Keep this window short (`'5m'`) to limit exposure. Refresh tokens are still session-bound, so a revoked refresh token cannot obtain new access tokens.
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
Logout → session deleted from DB
|
|
242
|
+
Request with old access token → accepted until exp (≤ 5m)
|
|
243
|
+
Request with old refresh token → rejected immediately (session gone)
|
|
244
|
+
```
|
|
145
245
|
|
|
146
246
|
---
|
|
147
247
|
|
|
148
|
-
##
|
|
248
|
+
## Automatic Token Refresh
|
|
249
|
+
|
|
250
|
+
When `protect()` encounters an expired access token, it performs one silent refresh before returning an error:
|
|
149
251
|
|
|
150
|
-
|
|
252
|
+
1. Reads the refresh token from the httpOnly cookie.
|
|
253
|
+
2. Rotates the session (deletes old, creates new).
|
|
254
|
+
3. Issues new access + refresh tokens.
|
|
255
|
+
4. Updates both cookies on the response.
|
|
256
|
+
5. Writes the new access token to the `X-New-Access-Token` response header.
|
|
257
|
+
6. Calls `next()` — the handler runs normally without any knowledge of the refresh.
|
|
151
258
|
|
|
152
|
-
|
|
259
|
+
If the refresh also fails (session expired or revoked), the middleware calls `next(SentriError)` with `UNAUTHORIZED` and the user must log in again.
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
Request with expired access token
|
|
263
|
+
→ protect() tries refresh cookie
|
|
264
|
+
→ refresh succeeds → new tokens set → request continues transparently
|
|
265
|
+
→ refresh fails → 401 UNAUTHORIZED — user must login
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Client-side:** Check the `X-New-Access-Token` header on every response to detect a silent refresh and update any in-memory token copy:
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
const newToken = response.headers.get('X-New-Access-Token');
|
|
272
|
+
if (newToken) currentAccessToken = newToken;
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
When `accessCookie` is configured the cookie is updated automatically, so in-memory management is optional.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Request Idempotency
|
|
280
|
+
|
|
281
|
+
`createIdempotencyMiddleware()` makes `POST`, `PUT`, and `PATCH` operations idempotent. When a client sends the same `X-Idempotency-Key` header twice, the second request receives the cached response without re-executing the handler.
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
import { createIdempotencyMiddleware } from 'sentri';
|
|
285
|
+
|
|
286
|
+
// Apply globally (before routes)
|
|
287
|
+
app.use(createIdempotencyMiddleware());
|
|
288
|
+
|
|
289
|
+
// Or scoped with custom options
|
|
290
|
+
apiRouter.use(createIdempotencyMiddleware({
|
|
291
|
+
ttl: 60_000, // cache TTL in ms — default: 300_000 (5 min)
|
|
292
|
+
header: 'X-Request-Id', // key header name — default: 'X-Idempotency-Key'
|
|
293
|
+
methods: ['POST'], // methods to watch — default: ['POST','PUT','PATCH']
|
|
294
|
+
}));
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### How it works
|
|
298
|
+
|
|
299
|
+
| Request | Key present | Cache hit | Behaviour |
|
|
300
|
+
|---|---|---|---|
|
|
301
|
+
| POST | No | — | Passes through unchanged |
|
|
302
|
+
| POST | Yes | No | Handler runs; response is cached (2xx only) |
|
|
303
|
+
| POST | Yes | Yes | Cached response returned immediately; `X-Idempotent-Replayed: true` header added |
|
|
304
|
+
|
|
305
|
+
The idempotency key is also attached as `req.requestId` and echoed in the `X-Request-Id` response header for all requests that include it.
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
app.post('/orders', createIdempotencyMiddleware(), async (req, res) => {
|
|
309
|
+
console.log(req.requestId); // the X-Idempotency-Key value
|
|
310
|
+
const order = await db.orders.create(req.body);
|
|
311
|
+
res.status(201).json({ error: false, data: order });
|
|
312
|
+
// On retry with same key → instant 201 from cache, no DB write
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Note:** The cache is in-memory and per-process. For multi-process deployments (clusters, containers) use a shared cache layer such as Redis.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Lifecycle Hooks
|
|
321
|
+
|
|
322
|
+
`hooks` lets you fire side effects at key points in the authentication flow. Every hook is optional and runs fire-and-forget — a rejected hook Promise is silently swallowed so a broken hook can never abort or delay a login request.
|
|
153
323
|
|
|
154
324
|
```typescript
|
|
155
325
|
export const auth = createAuth({
|
|
156
326
|
// ...
|
|
157
|
-
|
|
327
|
+
hooks: {
|
|
328
|
+
/** Called after every successful login. */
|
|
329
|
+
onLogin: async (user) => {
|
|
330
|
+
await auditLog.record('login', { userId: user.id, identifier: user.identifier });
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/** Called after every failed login attempt. */
|
|
334
|
+
onFailedLogin: (identifier, error) => {
|
|
335
|
+
rateLimiter.hit(`login:${identifier}`);
|
|
336
|
+
logger.warn('Login failed', { identifier, code: error.code });
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/** Called after logout (single session) and logout-all. */
|
|
340
|
+
onLogout: async (userId) => {
|
|
341
|
+
await cache.invalidate(userId);
|
|
342
|
+
},
|
|
343
|
+
},
|
|
158
344
|
});
|
|
159
345
|
```
|
|
160
346
|
|
|
161
|
-
|
|
347
|
+
### `AuthHooks` interface
|
|
348
|
+
|
|
349
|
+
| Hook | When | Receives |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| `onLogin` | After successful login | `AuthUser` |
|
|
352
|
+
| `onFailedLogin` | After failed login (wrong password or unknown identifier) | `identifier: string`, `error: SentriError` |
|
|
353
|
+
| `onLogout` | After `POST /logout-all` | `userId: string` |
|
|
354
|
+
|
|
355
|
+
> `POST /logout` (single session) does not fire `onLogout` — there is no authenticated user at that point. Use `onLogout` for "logout from all devices" events.
|
|
356
|
+
|
|
357
|
+
---
|
|
162
358
|
|
|
359
|
+
## Immediate Token Revocation (`isTokenRevoked`)
|
|
360
|
+
|
|
361
|
+
By default, sentri uses stateless access tokens — no DB lookup per request. If you need to revoke a token immediately (e.g. after a security incident), supply `isTokenRevoked`:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
export const auth = createAuth({
|
|
365
|
+
// ...
|
|
366
|
+
isTokenRevoked: async (sessionId) =>
|
|
367
|
+
await redis.sismember('revoked_sessions', sessionId),
|
|
368
|
+
});
|
|
163
369
|
```
|
|
164
|
-
|
|
370
|
+
|
|
371
|
+
After logout, add the `sessionId` to the revocation set:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// When handling a security incident — add to revoked set with a TTL
|
|
375
|
+
// equal to the access token lifetime so memory stays bounded.
|
|
376
|
+
await redis.sadd('revoked_sessions', sessionId);
|
|
377
|
+
await redis.expire('revoked_sessions', 300); // 5 min — matches accessExpiresIn
|
|
165
378
|
```
|
|
166
379
|
|
|
167
|
-
|
|
380
|
+
`isTokenRevoked` is called on **every** `protect()` invocation. Keep it fast (a single Redis `SISMEMBER`) or the latency benefit of stateless JWTs is lost. If a short revocation window equal to `accessExpiresIn` is acceptable, omit this hook entirely.
|
|
168
381
|
|
|
169
382
|
---
|
|
170
383
|
|
|
171
|
-
##
|
|
384
|
+
## apiKey — Restricting Registration
|
|
172
385
|
|
|
173
|
-
|
|
386
|
+
By default `POST /register` is open to the public. Set `apiKey` in your config to lock the endpoint:
|
|
174
387
|
|
|
175
|
-
|
|
388
|
+
```typescript
|
|
389
|
+
export const auth = createAuth({
|
|
390
|
+
// ...
|
|
391
|
+
apiKey: process.env.REGISTER_API_KEY!,
|
|
392
|
+
});
|
|
393
|
+
```
|
|
176
394
|
|
|
177
|
-
|
|
178
|
-
- `POST /logout-all` deletes **all** sessions for the user. Every access token across all devices is immediately rejected.
|
|
179
|
-
- Tokens issued before 1.1.0 (without the `sessionId` claim) are still accepted but bypass session validation — plan a rolling upgrade if you need strict enforcement for existing tokens.
|
|
395
|
+
Requests to `POST /register` must then include the header:
|
|
180
396
|
|
|
181
397
|
```
|
|
182
|
-
|
|
183
|
-
Request → protect() verifies JWT → checks session exists → ✓ allowed
|
|
184
|
-
Logout → session deleted
|
|
185
|
-
Request → protect() verifies JWT → session not found → ✗ 401 UNAUTHORIZED
|
|
398
|
+
X-Api-Key: <value of REGISTER_API_KEY>
|
|
186
399
|
```
|
|
187
400
|
|
|
188
|
-
|
|
401
|
+
Requests without the header, or with the wrong value, receive HTTP 401 `UNAUTHORIZED`. Keep the API key in an environment variable and share it only with trusted services.
|
|
189
402
|
|
|
190
403
|
---
|
|
191
404
|
|
|
@@ -193,8 +406,6 @@ Request → protect() verifies JWT → session not found → ✗ 401 UNAUTHORIZE
|
|
|
193
406
|
|
|
194
407
|
The `router` field in config lets you replace the built-in service logic for individual routes while the router still handles request parsing, input validation, and response formatting.
|
|
195
408
|
|
|
196
|
-
Each key is optional — only override what you need. Any key you omit falls back to the built-in behaviour.
|
|
197
|
-
|
|
198
409
|
```typescript
|
|
199
410
|
import { createAuth, SentriError } from 'sentri';
|
|
200
411
|
import type { AuthResult } from 'sentri';
|
|
@@ -222,15 +433,6 @@ export const auth = createAuth({
|
|
|
222
433
|
}
|
|
223
434
|
return result;
|
|
224
435
|
},
|
|
225
|
-
|
|
226
|
-
// Audit-log every token rotation
|
|
227
|
-
refresh: async (refreshToken) => {
|
|
228
|
-
const result = await defaultRefresh(refreshToken);
|
|
229
|
-
if (result.success) {
|
|
230
|
-
await auditLog.record('token_rotated', result.user.id);
|
|
231
|
-
}
|
|
232
|
-
return result;
|
|
233
|
-
},
|
|
234
436
|
},
|
|
235
437
|
});
|
|
236
438
|
```
|
|
@@ -246,8 +448,6 @@ export const auth = createAuth({
|
|
|
246
448
|
| `logoutAll` | `(userId: string) => Promise<void>` | `void` |
|
|
247
449
|
| `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
|
|
248
450
|
|
|
249
|
-
The router always validates the request body and URL parameters before calling any handler. Your function receives the already-validated, trimmed input.
|
|
250
|
-
|
|
251
451
|
---
|
|
252
452
|
|
|
253
453
|
## Adapter Interface
|
|
@@ -275,27 +475,6 @@ const adapter: AuthAdapter = {
|
|
|
275
475
|
|
|
276
476
|
`identifier` is a single string — the adapter decides what it maps to (email column, username column, phone, or a combined lookup). sentri never assumes the column name.
|
|
277
477
|
|
|
278
|
-
### Using the generated adapter
|
|
279
|
-
|
|
280
|
-
The generated `adapter.ts` exports `createAdapter(db)` so you can pass your existing database instance:
|
|
281
|
-
|
|
282
|
-
```typescript
|
|
283
|
-
// Prisma
|
|
284
|
-
import { PrismaClient } from '@prisma/client';
|
|
285
|
-
import { createAdapter } from './adapter.js';
|
|
286
|
-
|
|
287
|
-
const prisma = new PrismaClient();
|
|
288
|
-
export const adapter = createAdapter(prisma);
|
|
289
|
-
|
|
290
|
-
// Drizzle
|
|
291
|
-
import { db } from '../db.js';
|
|
292
|
-
import { createAdapter } from './adapter.js';
|
|
293
|
-
|
|
294
|
-
export const adapter = createAdapter(db);
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
`createAdapter` throws `SentriError` with code `CONFIGURATION_ERROR` at runtime if called without a `db` argument.
|
|
298
|
-
|
|
299
478
|
---
|
|
300
479
|
|
|
301
480
|
## Pre-built Router
|
|
@@ -303,20 +482,13 @@ export const adapter = createAdapter(db);
|
|
|
303
482
|
`auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
|
|
304
483
|
|
|
305
484
|
```typescript
|
|
306
|
-
{
|
|
307
|
-
error: boolean,
|
|
308
|
-
statusCode: number,
|
|
309
|
-
message: string,
|
|
310
|
-
data: T | null
|
|
311
|
-
}
|
|
485
|
+
{ error: boolean, statusCode: number, message: string, data: T | null }
|
|
312
486
|
```
|
|
313
487
|
|
|
314
488
|
### Endpoints
|
|
315
489
|
|
|
316
490
|
#### `POST /register`
|
|
317
491
|
|
|
318
|
-
Register a new user. Does **not** issue tokens — call `/login` after registration.
|
|
319
|
-
|
|
320
492
|
```
|
|
321
493
|
Headers: X-Api-Key: <key> (required when config.apiKey is set)
|
|
322
494
|
Body: { identifier, password, roles?: string[] }
|
|
@@ -324,84 +496,56 @@ Returns: { user: { id, identifier, roles } }
|
|
|
324
496
|
Status: 201
|
|
325
497
|
```
|
|
326
498
|
|
|
327
|
-
`password` must be 8–72 characters. `roles` must be a subset of `validRoles`.
|
|
328
|
-
|
|
329
|
-
---
|
|
330
|
-
|
|
331
499
|
#### `POST /login`
|
|
332
500
|
|
|
333
|
-
Authenticate a user and start a session.
|
|
334
|
-
|
|
335
501
|
```
|
|
336
502
|
Body: { identifier, password }
|
|
337
503
|
Returns: { accessToken, user: { id, identifier, roles } }
|
|
504
|
+
Cookies: access_token=<jwt> (when config.accessCookie is set)
|
|
505
|
+
refresh_token=<jwt> (httpOnly)
|
|
338
506
|
Status: 200
|
|
339
507
|
```
|
|
340
508
|
|
|
341
|
-
The refresh token is stored in an httpOnly cookie. The access token is returned in the response body.
|
|
342
|
-
|
|
343
|
-
---
|
|
344
|
-
|
|
345
509
|
#### `POST /refresh`
|
|
346
510
|
|
|
347
|
-
Exchange the refresh token cookie for a new access token. Implements session rotation — the old session is deleted and a new one is created.
|
|
348
|
-
|
|
349
511
|
```
|
|
350
|
-
Cookie: refresh_token=<token>
|
|
512
|
+
Cookie: refresh_token=<token>
|
|
351
513
|
Returns: { accessToken }
|
|
514
|
+
Cookies: access_token=<new-jwt> (when config.accessCookie is set)
|
|
515
|
+
refresh_token=<new-jwt> (rotated)
|
|
352
516
|
Status: 200
|
|
353
517
|
```
|
|
354
518
|
|
|
355
|
-
The new refresh token is written back to the cookie. No body required.
|
|
356
|
-
|
|
357
|
-
---
|
|
358
|
-
|
|
359
519
|
#### `POST /logout`
|
|
360
520
|
|
|
361
|
-
Invalidate the current session.
|
|
362
|
-
|
|
363
521
|
```
|
|
364
522
|
Cookie: refresh_token=<token>
|
|
523
|
+
Clears: access_token, refresh_token cookies
|
|
365
524
|
Returns: null
|
|
366
525
|
Status: 200
|
|
367
526
|
```
|
|
368
527
|
|
|
369
|
-
After logout, any access token bound to this session is immediately rejected by `protect()`. Safe to call even if the cookie is missing or the token is already expired.
|
|
370
|
-
|
|
371
|
-
---
|
|
372
|
-
|
|
373
528
|
#### `POST /logout-all`
|
|
374
529
|
|
|
375
|
-
Invalidate all sessions for the authenticated user (logout from every device).
|
|
376
|
-
|
|
377
530
|
```
|
|
378
|
-
Headers: Authorization: Bearer <accessToken>
|
|
531
|
+
Headers: Authorization: Bearer <accessToken> (or access_token cookie)
|
|
532
|
+
Clears: access_token, refresh_token cookies
|
|
379
533
|
Returns: null
|
|
380
534
|
Status: 200
|
|
381
535
|
```
|
|
382
536
|
|
|
383
|
-
All access tokens across all devices are immediately rejected by `protect()` after this call.
|
|
384
|
-
|
|
385
|
-
---
|
|
386
|
-
|
|
387
537
|
#### `GET /me`
|
|
388
538
|
|
|
389
|
-
Return the currently authenticated user.
|
|
390
|
-
|
|
391
539
|
```
|
|
392
|
-
Headers: Authorization: Bearer <accessToken>
|
|
540
|
+
Headers: Authorization: Bearer <accessToken> (or access_token cookie)
|
|
393
541
|
Returns: { id, identifier, roles }
|
|
394
542
|
Status: 200
|
|
395
543
|
```
|
|
396
544
|
|
|
397
|
-
---
|
|
398
|
-
|
|
399
545
|
#### `POST /users/:userId/roles`
|
|
400
546
|
|
|
401
|
-
Add roles to another user. Restricted to users with the `admin` role. Merges the given roles with the user's existing roles — no duplicates.
|
|
402
|
-
|
|
403
547
|
```
|
|
404
|
-
Headers: Authorization: Bearer <accessToken>
|
|
548
|
+
Headers: Authorization: Bearer <accessToken> (must have admin role)
|
|
405
549
|
Body: { roles: string[] }
|
|
406
550
|
Returns: { user: { id, identifier, roles } }
|
|
407
551
|
Status: 200
|
|
@@ -413,7 +557,12 @@ Status: 200
|
|
|
413
557
|
|
|
414
558
|
### `auth.protect()`
|
|
415
559
|
|
|
416
|
-
Verifies the
|
|
560
|
+
Verifies the access token and injects `request.user`. Token is read from:
|
|
561
|
+
|
|
562
|
+
1. `Authorization: Bearer <token>` header
|
|
563
|
+
2. `access_token` cookie (when `config.accessCookie` is set)
|
|
564
|
+
|
|
565
|
+
When the token has expired, a silent refresh is attempted automatically. On success the request proceeds normally; on failure HTTP 401 is returned.
|
|
417
566
|
|
|
418
567
|
```typescript
|
|
419
568
|
router.get('/dashboard', auth.protect(), (request, response) => {
|
|
@@ -422,23 +571,18 @@ router.get('/dashboard', auth.protect(), (request, response) => {
|
|
|
422
571
|
```
|
|
423
572
|
|
|
424
573
|
Returns HTTP 401 if:
|
|
425
|
-
-
|
|
426
|
-
- The token signature is invalid
|
|
427
|
-
- The
|
|
574
|
+
- No token is present in header or cookie
|
|
575
|
+
- The token signature is invalid
|
|
576
|
+
- The token is expired **and** the refresh also fails
|
|
428
577
|
|
|
429
578
|
---
|
|
430
579
|
|
|
431
580
|
### `auth.authorize(...roles)`
|
|
432
581
|
|
|
433
|
-
Enforces role-based access. Must be used **after** `auth.protect()`.
|
|
582
|
+
Enforces role-based access. Must be used **after** `auth.protect()`.
|
|
434
583
|
|
|
435
584
|
```typescript
|
|
436
|
-
router.delete(
|
|
437
|
-
'/posts/:id',
|
|
438
|
-
auth.protect(),
|
|
439
|
-
auth.authorize('admin'),
|
|
440
|
-
handler,
|
|
441
|
-
);
|
|
585
|
+
router.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
|
|
442
586
|
```
|
|
443
587
|
|
|
444
588
|
---
|
|
@@ -448,23 +592,19 @@ router.delete(
|
|
|
448
592
|
Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
|
|
449
593
|
|
|
450
594
|
```typescript
|
|
451
|
-
//
|
|
452
|
-
router.put(
|
|
453
|
-
'
|
|
454
|
-
auth.protect(),
|
|
455
|
-
auth.permit((request) => request.user!.id === request.params['id']),
|
|
595
|
+
// Ownership check
|
|
596
|
+
router.put('/users/:id', auth.protect(),
|
|
597
|
+
auth.permit((req) => req.user!.id === req.params['id']),
|
|
456
598
|
handler,
|
|
457
599
|
);
|
|
458
600
|
|
|
459
|
-
//
|
|
460
|
-
router.delete(
|
|
461
|
-
'/posts/:id',
|
|
462
|
-
auth.protect(),
|
|
601
|
+
// Role bypass + custom check
|
|
602
|
+
router.delete('/posts/:id', auth.protect(),
|
|
463
603
|
auth.permit({
|
|
464
604
|
roles: ['admin'],
|
|
465
|
-
check: async (
|
|
466
|
-
const post = await db.post.
|
|
467
|
-
return post?.authorId ===
|
|
605
|
+
check: async (req) => {
|
|
606
|
+
const post = await db.post.findById(req.params['id']);
|
|
607
|
+
return post?.authorId === req.user!.id;
|
|
468
608
|
},
|
|
469
609
|
}),
|
|
470
610
|
handler,
|
|
@@ -473,27 +613,42 @@ router.delete(
|
|
|
473
613
|
|
|
474
614
|
---
|
|
475
615
|
|
|
616
|
+
### `createIdempotencyMiddleware(options?)`
|
|
617
|
+
|
|
618
|
+
Makes mutating operations idempotent via `X-Idempotency-Key` header. See [Request Idempotency](#request-idempotency).
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
476
622
|
## Programmatic API
|
|
477
623
|
|
|
478
|
-
Token
|
|
624
|
+
### Token utilities
|
|
479
625
|
|
|
480
626
|
```typescript
|
|
481
|
-
// Token utilities
|
|
482
627
|
const accessToken = auth.signAccessToken({ id, identifier, roles });
|
|
483
628
|
const user = auth.verifyAccessToken(accessToken); // throws SentriError if invalid
|
|
484
629
|
const { sessionId } = auth.verifyRefreshToken(token); // throws SentriError if invalid
|
|
485
630
|
const refreshToken = auth.signRefreshToken(sessionId);
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Password utilities
|
|
486
634
|
|
|
487
|
-
|
|
635
|
+
```typescript
|
|
488
636
|
const hash = await auth.hashPassword('secret123');
|
|
489
637
|
const valid = await auth.verifyPassword('secret123', hash);
|
|
490
638
|
```
|
|
491
639
|
|
|
492
|
-
|
|
640
|
+
### Token extraction
|
|
493
641
|
|
|
494
|
-
|
|
642
|
+
```typescript
|
|
643
|
+
// Read the raw access token from Authorization header or access_token cookie
|
|
644
|
+
const token = auth.getCurrentAccessToken(request);
|
|
495
645
|
|
|
496
|
-
|
|
646
|
+
// Or import and use directly with a config object
|
|
647
|
+
import { getCurrentAccessToken } from 'sentri';
|
|
648
|
+
const token = getCurrentAccessToken(request, config);
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### Standalone `register`
|
|
497
652
|
|
|
498
653
|
```typescript
|
|
499
654
|
import { register } from 'sentri';
|
|
@@ -519,6 +674,7 @@ import type {
|
|
|
519
674
|
AuthConfig,
|
|
520
675
|
AuthClient,
|
|
521
676
|
AuthAdapter,
|
|
677
|
+
AuthHooks,
|
|
522
678
|
AuthUser,
|
|
523
679
|
AuthResult,
|
|
524
680
|
RegisterResult,
|
|
@@ -532,12 +688,21 @@ import type {
|
|
|
532
688
|
SessionRecord,
|
|
533
689
|
CreateUserData,
|
|
534
690
|
CookieConfig,
|
|
691
|
+
AccessCookieConfig,
|
|
692
|
+
IdempotencyOptions,
|
|
535
693
|
PermitCheck,
|
|
536
694
|
PermitOptions,
|
|
537
695
|
SentriErrorCode,
|
|
538
696
|
} from 'sentri';
|
|
539
697
|
|
|
540
|
-
import {
|
|
698
|
+
import {
|
|
699
|
+
SentriError,
|
|
700
|
+
AUTH_ERROR_STATUS,
|
|
701
|
+
createAuth,
|
|
702
|
+
register,
|
|
703
|
+
createIdempotencyMiddleware,
|
|
704
|
+
getCurrentAccessToken,
|
|
705
|
+
} from 'sentri';
|
|
541
706
|
```
|
|
542
707
|
|
|
543
708
|
---
|
|
@@ -553,7 +718,7 @@ All errors thrown by the library are instances of `SentriError` with a machine-r
|
|
|
553
718
|
| `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
|
|
554
719
|
| `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
|
|
555
720
|
| `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
|
|
556
|
-
| `UNAUTHORIZED` | 401 | No valid access token,
|
|
721
|
+
| `UNAUTHORIZED` | 401 | No valid access token, or session not found |
|
|
557
722
|
| `FORBIDDEN` | 403 | Authenticated but missing required role |
|
|
558
723
|
| `INVALID_ROLE` | 400 | Role name not in `validRoles` |
|
|
559
724
|
| `VALIDATION_ERROR` | 400 | Missing or invalid input field |
|
|
@@ -561,28 +726,16 @@ All errors thrown by the library are instances of `SentriError` with a machine-r
|
|
|
561
726
|
|
|
562
727
|
### `auth.errorHandler()`
|
|
563
728
|
|
|
564
|
-
Mount
|
|
729
|
+
Mount **after all your routes**:
|
|
565
730
|
|
|
566
731
|
```typescript
|
|
567
732
|
app.use('/auth', auth.router());
|
|
568
733
|
app.use('/api', apiRouter);
|
|
569
|
-
|
|
570
|
-
// Must be last
|
|
571
734
|
app.use(auth.errorHandler());
|
|
572
735
|
```
|
|
573
736
|
|
|
574
|
-
Optional logger for unexpected errors:
|
|
575
|
-
|
|
576
|
-
```typescript
|
|
577
|
-
app.use(auth.errorHandler({
|
|
578
|
-
onUnhandled: (err) => logger.error('Unexpected error', { err }),
|
|
579
|
-
}));
|
|
580
|
-
```
|
|
581
|
-
|
|
582
737
|
### Extending `SentriError`
|
|
583
738
|
|
|
584
|
-
Define application-specific errors by extending `SentriError`. They are caught automatically by `auth.errorHandler()` via `instanceof`:
|
|
585
|
-
|
|
586
739
|
```typescript
|
|
587
740
|
import { SentriError } from 'sentri';
|
|
588
741
|
|
|
@@ -592,13 +745,6 @@ export class NotFoundError extends SentriError {
|
|
|
592
745
|
}
|
|
593
746
|
}
|
|
594
747
|
|
|
595
|
-
export class PaymentError extends SentriError {
|
|
596
|
-
constructor(message: string) {
|
|
597
|
-
super('PAYMENT_FAILED', message, 402);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Throw anywhere in your routes — auth.errorHandler() catches them all
|
|
602
748
|
app.get('/items/:id', auth.protect(), async (req, res) => {
|
|
603
749
|
const item = await db.items.findById(req.params['id']);
|
|
604
750
|
if (!item) throw new NotFoundError('Item');
|
|
@@ -606,21 +752,27 @@ app.get('/items/:id', auth.protect(), async (req, res) => {
|
|
|
606
752
|
});
|
|
607
753
|
```
|
|
608
754
|
|
|
609
|
-
|
|
755
|
+
---
|
|
610
756
|
|
|
611
|
-
|
|
612
|
-
{
|
|
613
|
-
"error": true,
|
|
614
|
-
"statusCode": 404,
|
|
615
|
-
"code": "NOT_FOUND",
|
|
616
|
-
"message": "Item not found",
|
|
617
|
-
"data": null
|
|
618
|
-
}
|
|
619
|
-
```
|
|
757
|
+
## Migration Guide
|
|
620
758
|
|
|
621
|
-
|
|
759
|
+
### Breaking changes in 2.0.0
|
|
622
760
|
|
|
623
|
-
|
|
761
|
+
| What changed | Action required |
|
|
762
|
+
|---|---|
|
|
763
|
+
| `protect()` no longer performs a DB session check per request | Access tokens are now stateless. Logout does **not** immediately invalidate a live access token — it expires after `accessExpiresIn`. Keep this value short (`'5m'`). |
|
|
764
|
+
| `protect()` reads token from `access_token` cookie when `accessCookie` is configured | No action required if you use the `Authorization` header. Add `accessCookie` to config to enable cookie-based token delivery. |
|
|
765
|
+
| `protect()` silently refreshes expired tokens | Clients should read the `X-New-Access-Token` response header to detect and store the new token. |
|
|
766
|
+
|
|
767
|
+
### New in 2.0.0
|
|
768
|
+
|
|
769
|
+
- **`accessCookie`** — store the access token in a non-httpOnly cookie for SPA use.
|
|
770
|
+
- **`auth.getCurrentAccessToken(req)`** / **`getCurrentAccessToken(req, config)`** — extract the token from header or cookie.
|
|
771
|
+
- **`createIdempotencyMiddleware()`** — idempotent mutations via `X-Idempotency-Key`.
|
|
772
|
+
- **`hooks`** (`onLogin`, `onFailedLogin`, `onLogout`) — fire-and-forget lifecycle callbacks for audit logs, rate limiting, cache invalidation.
|
|
773
|
+
- **`isTokenRevoked`** — optional callback for Redis-backed immediate token revocation without giving up stateless JWTs.
|
|
774
|
+
- **Minified bundle** — library is built with tsup, reducing load time and install footprint.
|
|
775
|
+
- **Memoised config resolution** — `resolveConfig` is cached per config object; HMAC secret derivation is cached per secret string.
|
|
624
776
|
|
|
625
777
|
### Breaking changes in 1.1.0
|
|
626
778
|
|
|
@@ -628,7 +780,6 @@ Response shape for any `SentriError` (built-in or custom):
|
|
|
628
780
|
|---|---|
|
|
629
781
|
| `POST /signup` renamed to `POST /register` | Update all client-side calls |
|
|
630
782
|
| `RouterHandlers.signup` renamed to `RouterHandlers.register` | Update config if you used a custom signup handler |
|
|
631
|
-
| `protect()` now performs one DB read per request | Ensure your adapter's `session.findById` is indexed on session ID |
|
|
632
783
|
|
|
633
784
|
### Breaking changes in 1.2.0
|
|
634
785
|
|
|
@@ -639,10 +790,3 @@ Response shape for any `SentriError` (built-in or custom):
|
|
|
639
790
|
| `SignupResult` renamed to `RegisterResult` | Replace type references |
|
|
640
791
|
| `SignupInput` renamed to `RegisterInput` | Replace type references |
|
|
641
792
|
| `signup` service renamed to `register` | Replace `import { signup }` with `import { register }` |
|
|
642
|
-
|
|
643
|
-
### New features in 1.2.0
|
|
644
|
-
|
|
645
|
-
- **`auth.errorHandler()`** — built-in Express error handler mounted like `auth.router()`.
|
|
646
|
-
- **`SentriError.statusCode`** — each error carries its own HTTP status; no need for a manual status map.
|
|
647
|
-
- **Extensible errors** — subclass `SentriError` with a custom `code` and `statusCode`; `auth.errorHandler()` catches all subclasses automatically.
|
|
648
|
-
- **`register` exported** — the registration service function is now a named export for use outside the built-in router.
|