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.
Files changed (60) hide show
  1. package/README.md +325 -181
  2. package/dist/cli.d.ts +0 -2
  3. package/dist/cli.js +10 -103
  4. package/dist/index.d.ts +1046 -11
  5. package/dist/index.js +1 -5
  6. package/package.json +13 -6
  7. package/templates/drizzle/auth.ts +47 -4
  8. package/templates/prisma/auth.ts +47 -4
  9. package/dist/cli.d.ts.map +0 -1
  10. package/dist/cli.js.map +0 -1
  11. package/dist/client.d.ts +0 -160
  12. package/dist/client.d.ts.map +0 -1
  13. package/dist/client.js +0 -45
  14. package/dist/client.js.map +0 -1
  15. package/dist/errors/AuthError.d.ts +0 -99
  16. package/dist/errors/AuthError.d.ts.map +0 -1
  17. package/dist/errors/AuthError.js +0 -97
  18. package/dist/errors/AuthError.js.map +0 -1
  19. package/dist/index.d.ts.map +0 -1
  20. package/dist/index.js.map +0 -1
  21. package/dist/libs/config.d.ts +0 -62
  22. package/dist/libs/config.d.ts.map +0 -1
  23. package/dist/libs/config.js +0 -97
  24. package/dist/libs/config.js.map +0 -1
  25. package/dist/libs/hash.d.ts +0 -17
  26. package/dist/libs/hash.d.ts.map +0 -1
  27. package/dist/libs/hash.js +0 -22
  28. package/dist/libs/hash.js.map +0 -1
  29. package/dist/libs/token.d.ts +0 -46
  30. package/dist/libs/token.d.ts.map +0 -1
  31. package/dist/libs/token.js +0 -118
  32. package/dist/libs/token.js.map +0 -1
  33. package/dist/middleware/authorize.d.ts +0 -18
  34. package/dist/middleware/authorize.d.ts.map +0 -1
  35. package/dist/middleware/authorize.js +0 -30
  36. package/dist/middleware/authorize.js.map +0 -1
  37. package/dist/middleware/errorHandler.d.ts +0 -71
  38. package/dist/middleware/errorHandler.d.ts.map +0 -1
  39. package/dist/middleware/errorHandler.js +0 -74
  40. package/dist/middleware/errorHandler.js.map +0 -1
  41. package/dist/middleware/permit.d.ts +0 -62
  42. package/dist/middleware/permit.d.ts.map +0 -1
  43. package/dist/middleware/permit.js +0 -61
  44. package/dist/middleware/permit.js.map +0 -1
  45. package/dist/middleware/protect.d.ts +0 -31
  46. package/dist/middleware/protect.d.ts.map +0 -1
  47. package/dist/middleware/protect.js +0 -54
  48. package/dist/middleware/protect.js.map +0 -1
  49. package/dist/middleware/router.d.ts +0 -34
  50. package/dist/middleware/router.d.ts.map +0 -1
  51. package/dist/middleware/router.js +0 -264
  52. package/dist/middleware/router.js.map +0 -1
  53. package/dist/services/auth.d.ts +0 -85
  54. package/dist/services/auth.d.ts.map +0 -1
  55. package/dist/services/auth.js +0 -173
  56. package/dist/services/auth.js.map +0 -1
  57. package/dist/types/auth.d.ts +0 -450
  58. package/dist/types/auth.d.ts.map +0 -1
  59. package/dist/types/auth.js +0 -21
  60. 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-based authentication with session-bound access tokens, refresh token rotation, role-based access control, fine-grained permission checks, and a pre-built Express router — all behind a single typed client.
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 from 1.0.x](#migration-from-10x)
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: '15m', // default: '15m'
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
- // Restrict POST /register to callers that supply X-Api-Key header.
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
- cookie: { // optionalenables httpOnly cookie for refresh token
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', // default: 'refresh_token'
126
- // httpOnly: true, // default: true
127
- // sameSite: 'strict', // default: 'strict'
128
- // path: '/', // default: '/'
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 (`'15m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
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
- When `cookie` is configured, the refresh token is stored in an httpOnly cookie automatically. No `cookie-parser` middleware is needed.
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
- ## apiKey Restricting Registration
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
- By default `POST /register` is open to the public. This can be a security risk when your application allows role selection at registration time — any caller could register themselves as `admin`.
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
- Set `apiKey` in your config to lock the endpoint:
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
- apiKey: process.env.REGISTER_API_KEY!,
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
- Requests to `POST /register` must then include the header:
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
- X-Api-Key: <value of REGISTER_API_KEY>
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
- 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 (your back-office panel, CI scripts, etc.).
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
- ## Session-Bound Access Tokens
384
+ ## apiKey Restricting Registration
172
385
 
173
- Since version 1.1.0, access tokens embed the `sessionId` of the session that was created at login. The `protect()` middleware validates this session against the database on every request.
386
+ By default `POST /register` is open to the public. Set `apiKey` in your config to lock the endpoint:
174
387
 
175
- **What this means in practice:**
388
+ ```typescript
389
+ export const auth = createAuth({
390
+ // ...
391
+ apiKey: process.env.REGISTER_API_KEY!,
392
+ });
393
+ ```
176
394
 
177
- - `POST /logout` deletes the session. Any access token issued during that login is immediately rejected — even if it has not expired yet.
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
- Login → session created → access token embeds sessionId
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
- > **Trade-off:** `protect()` now performs one additional database read per request. For most applications this is negligible. If you need to avoid any per-request DB access, keep `accessExpiresIn` short (e.g. `'5m'`) and rely on token expiry instead but note that tokens will remain valid for up to `accessExpiresIn` after logout.
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> (set automatically by /login)
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> (must have admin role)
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 `Authorization: Bearer <token>` header, confirms the session is still active in the database, and injects `request.user` into the request.
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
- - The `Authorization` header is missing or malformed
426
- - The token signature is invalid or the token is expired
427
- - The session embedded in the token has been revoked (logout)
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()`. Passes if the user has at least one of the specified roles.
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
- // Simple ownership check
452
- router.put(
453
- '/users/:id',
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
- // Admins bypass the check; others must own the resource
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 (request) => {
466
- const post = await db.post.findUnique({ where: { id: request.params['id'] } });
467
- return post?.authorId === request.user!.id;
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 and password utilities are available on the auth client for use outside the built-in router.
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
- // Password utilities
635
+ ```typescript
488
636
  const hash = await auth.hashPassword('secret123');
489
637
  const valid = await auth.verifyPassword('secret123', hash);
490
638
  ```
491
639
 
492
- `verifyAccessToken` and `verifyRefreshToken` throw `SentriError` with code `TOKEN_EXPIRED` or `TOKEN_INVALID` — wrap them in a try/catch or use the router which handles this automatically.
640
+ ### Token extraction
493
641
 
494
- ### `register` — standalone service function
642
+ ```typescript
643
+ // Read the raw access token from Authorization header or access_token cookie
644
+ const token = auth.getCurrentAccessToken(request);
495
645
 
496
- The `register` function is also exported directly so you can call it outside the built-in router (e.g. in tests, scripts, or admin tools):
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 { SentriError, AUTH_ERROR_STATUS, createAuth, register } from 'sentri';
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, revoked session, or invalid API key |
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 `auth.errorHandler()` **after all your routes** to automatically format every `SentriError` (and any subclass) into the standard envelope:
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
- Response shape for any `SentriError` (built-in or custom):
755
+ ---
610
756
 
611
- ```json
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
- ## Migration from 1.0.x
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.