sentri 1.1.1 → 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 +388 -180
  2. package/dist/cli.d.ts +0 -2
  3. package/dist/cli.js +10 -103
  4. package/dist/index.d.ts +1046 -10
  5. package/dist/index.js +1 -4
  6. package/package.json +13 -6
  7. package/templates/drizzle/auth.ts +50 -7
  8. package/templates/prisma/auth.ts +50 -7
  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 -101
  16. package/dist/errors/AuthError.d.ts.map +0 -1
  17. package/dist/errors/AuthError.js +0 -99
  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 -73
  38. package/dist/middleware/errorHandler.d.ts.map +0 -1
  39. package/dist/middleware/errorHandler.js +0 -76
  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,6 +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)
27
+ - [Migration Guide](#migration-guide)
20
28
 
21
29
  ---
22
30
 
@@ -55,15 +63,26 @@ prisma/
55
63
  schema.prisma ← Prisma models (Prisma only, created or appended)
56
64
  ```
57
65
 
58
- ### 2. Mount the router
66
+ ### 2. Mount the router and error handler
59
67
 
60
68
  ```typescript
61
69
  import express from 'express';
62
70
  import { auth } from './lib/sentri/auth.js';
71
+ import { createIdempotencyMiddleware } from 'sentri';
63
72
 
64
73
  const app = express();
65
74
  app.use(express.json());
75
+
76
+ // Optional: make POST/PUT/PATCH operations idempotent
77
+ app.use(createIdempotencyMiddleware());
78
+
66
79
  app.use('/auth', auth.router());
80
+
81
+ // ... your routes ...
82
+
83
+ // Must be last — catches SentriError from sentri and your own subclasses
84
+ app.use(auth.errorHandler());
85
+ app.listen(3000);
67
86
  ```
68
87
 
69
88
  Done. All endpoints are available at `/auth/*`.
@@ -104,23 +123,43 @@ export const auth = createAuth({
104
123
  adapter: myAdapter, // required — see Adapter Interface
105
124
 
106
125
  // optional
107
- accessExpiresIn: '15m', // default: '15m'
126
+ accessExpiresIn: '5m', // default: '15m' — short is safe: auto-refresh handles it
108
127
  refreshExpiresIn: '7d', // default: '7d'
109
128
  algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
110
129
  saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
111
130
 
112
- // Restrict POST /register to callers that supply X-Api-Key header.
113
- // When set, only requests with this exact key can create new accounts.
131
+ // API key for POST /register see apiKey section
114
132
  apiKey: process.env.REGISTER_API_KEY, // optional
115
133
 
116
- 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: {
117
145
  secure: process.env.NODE_ENV === 'production',
118
- // name: 'refresh_token', // default: 'refresh_token'
119
- // httpOnly: true, // default: true
120
- // sameSite: 'strict', // default: 'strict'
121
- // path: '/', // default: '/'
146
+ // name: 'refresh_token', // default: 'refresh_token'
147
+ // httpOnly: true, // default: true
148
+ // sameSite: 'strict', // default: 'strict'
149
+ // path: '/', // default: '/'
122
150
  },
123
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
+
124
163
  // router: { // optional — replace built-in service logic per route
125
164
  // login: async (input) => { ... },
126
165
  // register: async (input) => { ... },
@@ -132,53 +171,234 @@ export const auth = createAuth({
132
171
  });
133
172
  ```
134
173
 
135
- `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.
136
201
 
137
- When `cookie` is configured, the refresh token is stored in an httpOnly cookie automatically. No `cookie-parser` middleware is needed.
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
+ ```
138
226
 
139
227
  ---
140
228
 
141
- ## apiKey Restricting Registration
229
+ ## Stateless Access Tokens
230
+
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
+ ```
142
245
 
143
- 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`.
246
+ ---
247
+
248
+ ## Automatic Token Refresh
249
+
250
+ When `protect()` encounters an expired access token, it performs one silent refresh before returning an error:
144
251
 
145
- Set `apiKey` in your config to lock the endpoint:
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.
258
+
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.
146
323
 
147
324
  ```typescript
148
325
  export const auth = createAuth({
149
326
  // ...
150
- 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
+ },
151
344
  });
152
345
  ```
153
346
 
154
- 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` |
155
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
+ ---
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
+ });
156
369
  ```
157
- 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
158
378
  ```
159
379
 
160
- 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.
161
381
 
162
382
  ---
163
383
 
164
- ## Session-Bound Access Tokens
384
+ ## apiKey Restricting Registration
165
385
 
166
- 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:
167
387
 
168
- **What this means in practice:**
388
+ ```typescript
389
+ export const auth = createAuth({
390
+ // ...
391
+ apiKey: process.env.REGISTER_API_KEY!,
392
+ });
393
+ ```
169
394
 
170
- - `POST /logout` deletes the session. Any access token issued during that login is immediately rejected — even if it has not expired yet.
171
- - `POST /logout-all` deletes **all** sessions for the user. Every access token across all devices is immediately rejected.
172
- - 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:
173
396
 
174
397
  ```
175
- Login → session created → access token embeds sessionId
176
- Request → protect() verifies JWT → checks session exists → ✓ allowed
177
- Logout → session deleted
178
- Request → protect() verifies JWT → session not found → ✗ 401 UNAUTHORIZED
398
+ X-Api-Key: <value of REGISTER_API_KEY>
179
399
  ```
180
400
 
181
- > **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.
182
402
 
183
403
  ---
184
404
 
@@ -186,10 +406,8 @@ Request → protect() verifies JWT → session not found → ✗ 401 UNAUTHORIZE
186
406
 
187
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.
188
408
 
189
- Each key is optional — only override what you need. Any key you omit falls back to the built-in behaviour.
190
-
191
409
  ```typescript
192
- import { createAuth, AuthError } from 'sentri';
410
+ import { createAuth, SentriError } from 'sentri';
193
411
  import type { AuthResult } from 'sentri';
194
412
 
195
413
  export const auth = createAuth({
@@ -202,7 +420,7 @@ export const auth = createAuth({
202
420
  login: async (input): Promise<AuthResult> => {
203
421
  const otpVerified = await redis.get(`otp:${input.identifier}`);
204
422
  if (!otpVerified) {
205
- return { success: false, error: new AuthError('INVALID_CREDENTIALS', 'OTP required') };
423
+ return { success: false, error: new SentriError('INVALID_CREDENTIALS', 'OTP required') };
206
424
  }
207
425
  return defaultLogin(input);
208
426
  },
@@ -215,15 +433,6 @@ export const auth = createAuth({
215
433
  }
216
434
  return result;
217
435
  },
218
-
219
- // Audit-log every token rotation
220
- refresh: async (refreshToken) => {
221
- const result = await defaultRefresh(refreshToken);
222
- if (result.success) {
223
- await auditLog.record('token_rotated', result.user.id);
224
- }
225
- return result;
226
- },
227
436
  },
228
437
  });
229
438
  ```
@@ -232,15 +441,13 @@ export const auth = createAuth({
232
441
 
233
442
  | Key | Signature | Must return |
234
443
  |---|---|---|
235
- | `register` | `(input: SignupInput) => Promise<SignupResult>` | `SignupResult` |
444
+ | `register` | `(input: RegisterInput) => Promise<RegisterResult>` | `RegisterResult` |
236
445
  | `login` | `(input: LoginInput) => Promise<AuthResult>` | `AuthResult` |
237
446
  | `refresh` | `(refreshToken: string) => Promise<RefreshResult>` | `RefreshResult` |
238
447
  | `logout` | `(refreshToken: string \| undefined) => Promise<void>` | `void` |
239
448
  | `logoutAll` | `(userId: string) => Promise<void>` | `void` |
240
449
  | `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
241
450
 
242
- The router always validates the request body and URL parameters before calling any handler. Your function receives the already-validated, trimmed input.
243
-
244
451
  ---
245
452
 
246
453
  ## Adapter Interface
@@ -268,27 +475,6 @@ const adapter: AuthAdapter = {
268
475
 
269
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.
270
477
 
271
- ### Using the generated adapter
272
-
273
- The generated `adapter.ts` exports `createAdapter(db)` so you can pass your existing database instance:
274
-
275
- ```typescript
276
- // Prisma
277
- import { PrismaClient } from '@prisma/client';
278
- import { createAdapter } from './adapter.js';
279
-
280
- const prisma = new PrismaClient();
281
- export const adapter = createAdapter(prisma);
282
-
283
- // Drizzle
284
- import { db } from '../db.js';
285
- import { createAdapter } from './adapter.js';
286
-
287
- export const adapter = createAdapter(db);
288
- ```
289
-
290
- `createAdapter` throws `AuthError` with code `CONFIGURATION_ERROR` at runtime if called without a `db` argument.
291
-
292
478
  ---
293
479
 
294
480
  ## Pre-built Router
@@ -296,20 +482,13 @@ export const adapter = createAdapter(db);
296
482
  `auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
297
483
 
298
484
  ```typescript
299
- {
300
- error: boolean,
301
- statusCode: number,
302
- message: string,
303
- data: T | null
304
- }
485
+ { error: boolean, statusCode: number, message: string, data: T | null }
305
486
  ```
306
487
 
307
488
  ### Endpoints
308
489
 
309
490
  #### `POST /register`
310
491
 
311
- Register a new user. Does **not** issue tokens — call `/login` after registration.
312
-
313
492
  ```
314
493
  Headers: X-Api-Key: <key> (required when config.apiKey is set)
315
494
  Body: { identifier, password, roles?: string[] }
@@ -317,84 +496,56 @@ Returns: { user: { id, identifier, roles } }
317
496
  Status: 201
318
497
  ```
319
498
 
320
- `password` must be 8–72 characters. `roles` must be a subset of `validRoles`.
321
-
322
- ---
323
-
324
499
  #### `POST /login`
325
500
 
326
- Authenticate a user and start a session.
327
-
328
501
  ```
329
502
  Body: { identifier, password }
330
503
  Returns: { accessToken, user: { id, identifier, roles } }
504
+ Cookies: access_token=<jwt> (when config.accessCookie is set)
505
+ refresh_token=<jwt> (httpOnly)
331
506
  Status: 200
332
507
  ```
333
508
 
334
- The refresh token is stored in an httpOnly cookie. The access token is returned in the response body.
335
-
336
- ---
337
-
338
509
  #### `POST /refresh`
339
510
 
340
- Exchange the refresh token cookie for a new access token. Implements session rotation — the old session is deleted and a new one is created.
341
-
342
511
  ```
343
- Cookie: refresh_token=<token> (set automatically by /login)
512
+ Cookie: refresh_token=<token>
344
513
  Returns: { accessToken }
514
+ Cookies: access_token=<new-jwt> (when config.accessCookie is set)
515
+ refresh_token=<new-jwt> (rotated)
345
516
  Status: 200
346
517
  ```
347
518
 
348
- The new refresh token is written back to the cookie. No body required.
349
-
350
- ---
351
-
352
519
  #### `POST /logout`
353
520
 
354
- Invalidate the current session.
355
-
356
521
  ```
357
522
  Cookie: refresh_token=<token>
523
+ Clears: access_token, refresh_token cookies
358
524
  Returns: null
359
525
  Status: 200
360
526
  ```
361
527
 
362
- 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.
363
-
364
- ---
365
-
366
528
  #### `POST /logout-all`
367
529
 
368
- Invalidate all sessions for the authenticated user (logout from every device).
369
-
370
530
  ```
371
- Headers: Authorization: Bearer <accessToken>
531
+ Headers: Authorization: Bearer <accessToken> (or access_token cookie)
532
+ Clears: access_token, refresh_token cookies
372
533
  Returns: null
373
534
  Status: 200
374
535
  ```
375
536
 
376
- All access tokens across all devices are immediately rejected by `protect()` after this call.
377
-
378
- ---
379
-
380
537
  #### `GET /me`
381
538
 
382
- Return the currently authenticated user.
383
-
384
539
  ```
385
- Headers: Authorization: Bearer <accessToken>
540
+ Headers: Authorization: Bearer <accessToken> (or access_token cookie)
386
541
  Returns: { id, identifier, roles }
387
542
  Status: 200
388
543
  ```
389
544
 
390
- ---
391
-
392
545
  #### `POST /users/:userId/roles`
393
546
 
394
- Add roles to another user. Restricted to users with the `admin` role. Merges the given roles with the user's existing roles — no duplicates.
395
-
396
547
  ```
397
- Headers: Authorization: Bearer <accessToken> (must have admin role)
548
+ Headers: Authorization: Bearer <accessToken> (must have admin role)
398
549
  Body: { roles: string[] }
399
550
  Returns: { user: { id, identifier, roles } }
400
551
  Status: 200
@@ -406,7 +557,12 @@ Status: 200
406
557
 
407
558
  ### `auth.protect()`
408
559
 
409
- 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.
410
566
 
411
567
  ```typescript
412
568
  router.get('/dashboard', auth.protect(), (request, response) => {
@@ -415,23 +571,18 @@ router.get('/dashboard', auth.protect(), (request, response) => {
415
571
  ```
416
572
 
417
573
  Returns HTTP 401 if:
418
- - The `Authorization` header is missing or malformed
419
- - The token signature is invalid or the token is expired
420
- - 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
421
577
 
422
578
  ---
423
579
 
424
580
  ### `auth.authorize(...roles)`
425
581
 
426
- 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()`.
427
583
 
428
584
  ```typescript
429
- router.delete(
430
- '/posts/:id',
431
- auth.protect(),
432
- auth.authorize('admin'),
433
- handler,
434
- );
585
+ router.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
435
586
  ```
436
587
 
437
588
  ---
@@ -441,23 +592,19 @@ router.delete(
441
592
  Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
442
593
 
443
594
  ```typescript
444
- // Simple ownership check
445
- router.put(
446
- '/users/:id',
447
- auth.protect(),
448
- 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']),
449
598
  handler,
450
599
  );
451
600
 
452
- // Admins bypass the check; others must own the resource
453
- router.delete(
454
- '/posts/:id',
455
- auth.protect(),
601
+ // Role bypass + custom check
602
+ router.delete('/posts/:id', auth.protect(),
456
603
  auth.permit({
457
604
  roles: ['admin'],
458
- check: async (request) => {
459
- const post = await db.post.findUnique({ where: { id: request.params['id'] } });
460
- 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;
461
608
  },
462
609
  }),
463
610
  handler,
@@ -466,23 +613,57 @@ router.delete(
466
613
 
467
614
  ---
468
615
 
616
+ ### `createIdempotencyMiddleware(options?)`
617
+
618
+ Makes mutating operations idempotent via `X-Idempotency-Key` header. See [Request Idempotency](#request-idempotency).
619
+
620
+ ---
621
+
469
622
  ## Programmatic API
470
623
 
471
- Token and password utilities are available on the auth client for use outside the built-in router.
624
+ ### Token utilities
472
625
 
473
626
  ```typescript
474
- // Token utilities
475
627
  const accessToken = auth.signAccessToken({ id, identifier, roles });
476
- const user = auth.verifyAccessToken(accessToken); // throws AuthError if invalid
477
- const { sessionId } = auth.verifyRefreshToken(token); // throws AuthError if invalid
628
+ const user = auth.verifyAccessToken(accessToken); // throws SentriError if invalid
629
+ const { sessionId } = auth.verifyRefreshToken(token); // throws SentriError if invalid
478
630
  const refreshToken = auth.signRefreshToken(sessionId);
631
+ ```
479
632
 
480
- // Password utilities
633
+ ### Password utilities
634
+
635
+ ```typescript
481
636
  const hash = await auth.hashPassword('secret123');
482
637
  const valid = await auth.verifyPassword('secret123', hash);
483
638
  ```
484
639
 
485
- `verifyAccessToken` and `verifyRefreshToken` throw `AuthError` with code `TOKEN_EXPIRED` or `TOKEN_INVALID` — wrap them in a try/catch or use the router which handles this automatically.
640
+ ### Token extraction
641
+
642
+ ```typescript
643
+ // Read the raw access token from Authorization header or access_token cookie
644
+ const token = auth.getCurrentAccessToken(request);
645
+
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`
652
+
653
+ ```typescript
654
+ import { register } from 'sentri';
655
+
656
+ const result = await register(
657
+ { identifier: 'alice@example.com', password: 'hunter2', roles: ['user'] },
658
+ config,
659
+ );
660
+
661
+ if (result.success) {
662
+ console.log(result.user.id);
663
+ } else {
664
+ console.error(result.error.code); // 'USER_ALREADY_EXISTS' | 'INVALID_ROLE'
665
+ }
666
+ ```
486
667
 
487
668
  ---
488
669
 
@@ -493,32 +674,42 @@ import type {
493
674
  AuthConfig,
494
675
  AuthClient,
495
676
  AuthAdapter,
677
+ AuthHooks,
496
678
  AuthUser,
497
679
  AuthResult,
498
- SignupResult,
680
+ RegisterResult,
499
681
  AssignRolesResult,
500
682
  RefreshResult,
501
683
  ApiResponse,
502
- SignupInput,
684
+ RegisterInput,
503
685
  LoginInput,
504
686
  RouterHandlers,
505
687
  UserRecord,
506
688
  SessionRecord,
507
689
  CreateUserData,
508
690
  CookieConfig,
691
+ AccessCookieConfig,
692
+ IdempotencyOptions,
509
693
  PermitCheck,
510
694
  PermitOptions,
511
- AuthErrorCode,
695
+ SentriErrorCode,
512
696
  } from 'sentri';
513
697
 
514
- import { AuthError, createAuth } from 'sentri';
698
+ import {
699
+ SentriError,
700
+ AUTH_ERROR_STATUS,
701
+ createAuth,
702
+ register,
703
+ createIdempotencyMiddleware,
704
+ getCurrentAccessToken,
705
+ } from 'sentri';
515
706
  ```
516
707
 
517
708
  ---
518
709
 
519
710
  ## Error Handling
520
711
 
521
- All errors thrown by the library are instances of `AuthError` with a machine-readable `code`:
712
+ All errors thrown by the library are instances of `SentriError` with a machine-readable `code` and an HTTP `statusCode`:
522
713
 
523
714
  | Code | HTTP | Meaning |
524
715
  |---|---|---|
@@ -527,58 +718,75 @@ All errors thrown by the library are instances of `AuthError` with a machine-rea
527
718
  | `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
528
719
  | `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
529
720
  | `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
530
- | `UNAUTHORIZED` | 401 | No valid access token, revoked session, or invalid API key |
721
+ | `UNAUTHORIZED` | 401 | No valid access token, or session not found |
531
722
  | `FORBIDDEN` | 403 | Authenticated but missing required role |
532
723
  | `INVALID_ROLE` | 400 | Role name not in `validRoles` |
533
724
  | `VALIDATION_ERROR` | 400 | Missing or invalid input field |
534
725
  | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` configuration |
535
726
 
536
- The built-in router converts all `AuthError` instances to the standard envelope automatically. For custom routes:
727
+ ### `auth.errorHandler()`
728
+
729
+ Mount **after all your routes**:
537
730
 
538
731
  ```typescript
539
- import { AuthError } from 'sentri';
540
-
541
- const AUTH_ERROR_STATUS: Record<string, number> = {
542
- UNAUTHORIZED: 401,
543
- TOKEN_EXPIRED: 401,
544
- TOKEN_INVALID: 401,
545
- INVALID_CREDENTIALS: 401,
546
- FORBIDDEN: 403,
547
- USER_NOT_FOUND: 404,
548
- USER_ALREADY_EXISTS: 409,
549
- INVALID_ROLE: 400,
550
- VALIDATION_ERROR: 400,
551
- CONFIGURATION_ERROR: 500,
552
- };
732
+ app.use('/auth', auth.router());
733
+ app.use('/api', apiRouter);
734
+ app.use(auth.errorHandler());
735
+ ```
736
+
737
+ ### Extending `SentriError`
553
738
 
554
- app.use((error, _request, response, next) => {
555
- if (error instanceof AuthError) {
556
- const statusCode = AUTH_ERROR_STATUS[error.code] ?? 500;
557
- return response.status(statusCode).json({
558
- error: true,
559
- statusCode,
560
- code: error.code,
561
- message: error.message,
562
- data: null,
563
- });
739
+ ```typescript
740
+ import { SentriError } from 'sentri';
741
+
742
+ export class NotFoundError extends SentriError {
743
+ constructor(resource: string) {
744
+ super('NOT_FOUND', `${resource} not found`, 404);
564
745
  }
565
- next(error);
746
+ }
747
+
748
+ app.get('/items/:id', auth.protect(), async (req, res) => {
749
+ const item = await db.items.findById(req.params['id']);
750
+ if (!item) throw new NotFoundError('Item');
751
+ res.json(item);
566
752
  });
567
753
  ```
568
754
 
569
755
  ---
570
756
 
571
- ## Migration from 1.0.x
757
+ ## Migration Guide
572
758
 
573
- ### Breaking changes
759
+ ### Breaking changes in 2.0.0
760
+
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.
776
+
777
+ ### Breaking changes in 1.1.0
574
778
 
575
779
  | What changed | Action required |
576
780
  |---|---|
577
781
  | `POST /signup` renamed to `POST /register` | Update all client-side calls |
578
782
  | `RouterHandlers.signup` renamed to `RouterHandlers.register` | Update config if you used a custom signup handler |
579
- | `protect()` now performs one DB read per request | Ensure your adapter's `session.findById` is indexed on session ID |
580
783
 
581
- ### New features
784
+ ### Breaking changes in 1.2.0
582
785
 
583
- - **Session-bound tokens** access tokens are immediately invalidated after logout without waiting for expiry.
584
- - **`apiKey` config** — lock `POST /register` to trusted callers via `X-Api-Key` header.
786
+ | What changed | Action required |
787
+ |---|---|
788
+ | `AuthError` renamed to `SentriError` | Replace all `import { AuthError }` with `import { SentriError }` |
789
+ | `AuthErrorCode` renamed to `SentriErrorCode` | Replace all `AuthErrorCode` type references |
790
+ | `SignupResult` renamed to `RegisterResult` | Replace type references |
791
+ | `SignupInput` renamed to `RegisterInput` | Replace type references |
792
+ | `signup` service renamed to `register` | Replace `import { signup }` with `import { register }` |