sentri 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # sentri
2
2
 
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.
3
+ Auth and authorization library for Express. Supports two modes:
4
+
5
+ - **Server mode** — runs as a standalone auth server with its own database schema (Kysely), issues JWTs, and exposes auth endpoints including a public key endpoint for SSO.
6
+ - **Client mode** — used by other apps to validate tokens issued by the auth server, without a database.
4
7
 
5
8
  ---
6
9
 
@@ -8,22 +11,15 @@ Auth and authorization library for Express + PostgreSQL. Provides stateless JWT
8
11
 
9
12
  - [Installation](#installation)
10
13
  - [Quick Start](#quick-start)
11
- - [CLI](#cli)
14
+ - [Server Mode](#server-mode)
15
+ - [Client Mode](#client-mode)
16
+ - [SSO Flow](#sso-flow)
12
17
  - [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)
20
- - [Custom Route Handlers](#custom-route-handlers)
21
- - [Adapter Interface](#adapter-interface)
22
- - [Pre-built Router](#pre-built-router)
18
+ - [Endpoints](#endpoints)
23
19
  - [Middleware](#middleware)
24
- - [Programmatic API](#programmatic-api)
25
- - [Types](#types)
20
+ - [Token Utilities](#token-utilities)
26
21
  - [Error Handling](#error-handling)
22
+ - [Request Idempotency](#request-idempotency)
27
23
  - [Migration Guide](#migration-guide)
28
24
 
29
25
  ---
@@ -34,522 +30,267 @@ Auth and authorization library for Express + PostgreSQL. Provides stateless JWT
34
30
  npm install sentri
35
31
  ```
36
32
 
37
- **Peer dependency:** `express >= 4.0.0`
38
-
39
- ---
40
-
41
- ## Quick Start
33
+ `kysely` and `pg` are bundled with sentri — no separate installation needed for PostgreSQL.
42
34
 
43
- ### 1. Generate templates
35
+ For other databases, install the driver:
44
36
 
45
37
  ```bash
46
- # Prisma
47
- npx sentri generate prisma
38
+ # MySQL
39
+ npm install mysql2
48
40
 
49
- # Drizzle
50
- npx sentri generate drizzle
41
+ # SQLite
42
+ npm install better-sqlite3
51
43
  ```
52
44
 
53
- This creates:
45
+ **Peer dependency:** `express >= 4.0.0`
54
46
 
55
- ```
56
- src/lib/
57
- index.ts ← barrel export
58
- sentri/
59
- adapter.ts ← AuthAdapter implementation
60
- auth.ts ← configured auth client
61
- schema.ts ← table definitions (Drizzle only)
62
- prisma/
63
- schema.prisma ← Prisma models (Prisma only, created or appended)
64
- ```
47
+ ---
48
+
49
+ ## Quick Start
65
50
 
66
- ### 2. Mount the router and error handler
51
+ ### Server Mode PostgreSQL (recommended)
67
52
 
68
53
  ```typescript
69
54
  import express from 'express';
70
- import { auth } from './lib/sentri/auth.js';
71
- import { createIdempotencyMiddleware } from 'sentri';
55
+ import { createAuthServer } from 'sentri';
56
+
57
+ const auth = createAuthServer({
58
+ validRoles: ['user', 'admin'] as const,
59
+ db: { connectionString: process.env.DATABASE_URL! },
60
+ });
72
61
 
73
62
  const app = express();
74
63
  app.use(express.json());
64
+ app.use(auth.idempotencyMiddleware());
75
65
 
76
- // Optional: make POST/PUT/PATCH operations idempotent
77
- app.use(createIdempotencyMiddleware());
78
-
66
+ await auth.migrate();
79
67
  app.use('/auth', auth.router());
80
68
 
81
- // ... your routes ...
82
-
83
- // Must be last — catches SentriError from sentri and your own subclasses
69
+ app.get('/me', auth.protect(), (req, res) => res.json(req.user));
84
70
  app.use(auth.errorHandler());
85
71
  app.listen(3000);
86
72
  ```
87
73
 
88
- Done. All endpoints are available at `/auth/*`.
89
-
90
- ---
91
-
92
- ## CLI
93
-
94
- ### `sentri generate <prisma|drizzle>`
95
-
96
- Generates adapter, auth config, and schema templates in one command.
97
-
98
- ```bash
99
- npx sentri generate prisma
100
- npx sentri generate drizzle
101
- ```
74
+ `createAuthServer()` generates an RSA-2048 key pair at startup and enables `GET /keys` automatically.
102
75
 
103
- **What gets created:**
104
-
105
- | File | Behavior |
106
- |---|---|
107
- | `src/lib/sentri/adapter.ts` | Created fresh (error if exists) |
108
- | `src/lib/sentri/auth.ts` | Created fresh (error if exists) |
109
- | `src/lib/sentri/schema.ts` | Drizzle only — created fresh or tables appended |
110
- | `prisma/schema.prisma` | Prisma only — created fresh or models appended |
111
- | `src/lib/index.ts` | Created fresh, skipped if already exists |
112
-
113
- ---
114
-
115
- ## Configuration
76
+ ### Server Mode — Custom Dialect
116
77
 
117
78
  ```typescript
79
+ import express from 'express';
118
80
  import { createAuth } from 'sentri';
119
-
120
- export const auth = createAuth({
121
- secret: process.env.JWT_SECRET!, // required — keep in env, min 32 chars
122
- validRoles: ['user', 'admin'] as const, // required — use `as const` for type safety
123
- adapter: myAdapter, // required — see Adapter Interface
124
-
125
- // optional
126
- accessExpiresIn: '5m', // default: '15m' — short is safe: auto-refresh handles it
127
- refreshExpiresIn: '7d', // default: '7d'
128
- algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
129
- saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
130
-
131
- // API key for POST /register — see apiKey section
132
- apiKey: process.env.REGISTER_API_KEY, // optional
133
-
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: {
145
- secure: process.env.NODE_ENV === 'production',
146
- // name: 'refresh_token', // default: 'refresh_token'
147
- // httpOnly: true, // default: true
148
- // sameSite: 'strict', // default: 'strict'
149
- // path: '/', // default: '/'
150
- },
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
-
163
- // router: { // optional — replace built-in service logic per route
164
- // login: async (input) => { ... },
165
- // register: async (input) => { ... },
166
- // refresh: async (refreshToken) => { ... },
167
- // logout: async (refreshToken) => { ... },
168
- // logoutAll: async (userId) => { ... },
169
- // assignRoles: async (userId, roles) => { ... },
170
- // },
171
- });
172
- ```
173
-
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
- // ...
81
+ import { PostgresDialect } from 'kysely'; // kysely is bundled, no install needed
82
+ import { Pool } from 'pg'; // pg is bundled, no install needed
83
+
84
+ const auth = createAuth({
85
+ mode: 'server',
86
+ dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL }) }),
87
+ secret: process.env.JWT_PRIVATE_KEY!, // RSA private key PEM (RS256) or plain string (HS256)
88
+ algorithm: 'RS256',
89
+ validRoles: ['user', 'admin'] as const,
197
90
  });
198
- ```
199
91
 
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];
92
+ const app = express();
93
+ app.use(express.json());
94
+ await auth.migrate();
95
+ app.use('/auth', auth.router());
96
+ app.use(auth.errorHandler());
97
+ app.listen(3000);
210
98
  ```
211
99
 
212
- ### Reading the token server-side
100
+ ### Client Mode (Other Apps)
213
101
 
214
102
  ```typescript
215
- import { getCurrentAccessToken } from 'sentri';
103
+ import express from 'express';
104
+ import { createAuth } from 'sentri';
216
105
 
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 });
106
+ const auth = createAuth({
107
+ mode: 'client',
108
+ keyUri: 'https://auth.myapp.com/auth/keys',
221
109
  });
222
110
 
223
- // Or via the auth client (pre-bound to config):
224
- const token = auth.getCurrentAccessToken(req);
225
- ```
226
-
227
- ---
228
-
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.
111
+ const app = express();
112
+ app.get('/products', auth.protect(), auth.authorize('admin'), handler);
113
+ app.get('/orders', auth.protect(), auth.permit(req => req.user!.id === req.params.userId), handler);
239
114
 
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)
115
+ app.use(auth.errorHandler());
244
116
  ```
245
117
 
246
118
  ---
247
119
 
248
- ## Automatic Token Refresh
249
-
250
- When `protect()` encounters an expired access token, it performs one silent refresh before returning an error:
251
-
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
- ---
120
+ ## Server Mode
278
121
 
279
- ## Request Idempotency
122
+ Server mode manages users, sessions, and tokens entirely within Sentri. The user provides a database dialect; Sentri handles the schema.
280
123
 
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.
124
+ ### `createAuthServer(options)` PostgreSQL shortcut
282
125
 
283
126
  ```typescript
284
- import { createIdempotencyMiddleware } from 'sentri';
127
+ import { createAuthServer } from 'sentri';
285
128
 
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
129
+ const auth = createAuthServer({
130
+ validRoles: ['user', 'admin'] as const,
298
131
 
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 |
132
+ // Connection string
133
+ db: { connectionString: process.env.DATABASE_URL!, max: 10 },
304
134
 
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.
135
+ // or individual params
136
+ // db: { host: 'localhost', port: 5432, database: 'mydb', user: 'app', password: 'secret' },
306
137
 
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
138
+ // Optional
139
+ accessExpiresIn: '15m',
140
+ refreshExpiresIn: '7d',
141
+ saltRounds: 12,
142
+ apiKey: process.env.REGISTER_API_KEY,
143
+ redisUrl: process.env.REDIS_URL, // enables Redis-backed idempotency cache
313
144
  });
314
145
  ```
315
146
 
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.
147
+ ### `createAuth(config)` Full config
323
148
 
324
149
  ```typescript
325
- export const auth = createAuth({
326
- // ...
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
- },
150
+ createAuth({
151
+ mode: 'server',
152
+ dialect, // required — Kysely Dialect
153
+ secret: process.env.JWT_SECRET!, // required RSA private key PEM (RS256) or string (HS256)
154
+ validRoles: ['user', 'admin'] as const, // required
155
+
156
+ algorithm: 'RS256', // default: 'HS256' | also: 'HS384','HS512','RS384','RS512'
157
+ accessExpiresIn: '15m', // default: '15m'
158
+ refreshExpiresIn: '7d', // default: '7d'
159
+ saltRounds: 12, // default: 12 (bcrypt rounds, 10–31)
160
+ apiKey: process.env.REGISTER_API_KEY, // restricts POST /register
161
+ redisUrl: process.env.REDIS_URL, // Redis URL for idempotency cache
162
+ cookie: { secure: true }, // httpOnly refresh token cookie
163
+ accessCookie: { secure: true }, // non-httpOnly access token cookie (SPA)
164
+ hooks: { onLogin, onFailedLogin, onLogout },
165
+ isTokenRevoked: async (sessionId) => await redis.sismember('revoked', sessionId),
166
+ router: { // override built-in service functions
167
+ login, register, refresh, logout, logoutAll, assignRoles,
168
+ changeIdentifier, changePassword,
343
169
  },
344
170
  });
345
171
  ```
346
172
 
347
- ### `AuthHooks` interface
173
+ ### Database Migration
348
174
 
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` |
175
+ ```typescript
176
+ // Call once at startup — uses IF NOT EXISTS, safe to repeat
177
+ await auth.migrate();
178
+ ```
354
179
 
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.
180
+ Creates two tables:
181
+ - `sentri_users` — id, identifier, password_hash, roles (JSON), created_at
182
+ - `sentri_sessions` — id, user_id, expires_at, created_at
356
183
 
357
184
  ---
358
185
 
359
- ## Immediate Token Revocation (`isTokenRevoked`)
186
+ ## Client Mode
360
187
 
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`:
188
+ Client mode has no database. It fetches the auth server's public key and validates JWTs stateless.
362
189
 
363
190
  ```typescript
364
- export const auth = createAuth({
365
- // ...
366
- isTokenRevoked: async (sessionId) =>
367
- await redis.sismember('revoked_sessions', sessionId),
191
+ createAuth({
192
+ mode: 'client',
193
+ keyUri: 'https://auth.myapp.com/auth/keys', // required
194
+ validRoles: ['admin', 'user'], // optional — TypeScript type safety only
368
195
  });
369
196
  ```
370
197
 
371
- After logout, add the `sessionId` to the revocation set:
198
+ Available methods: `protect()`, `authorize()`, `permit()`, `errorHandler()`.
372
199
 
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
378
- ```
379
-
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.
200
+ The public key is fetched once and cached for 1 hour. Token validation is fully stateless.
381
201
 
382
202
  ---
383
203
 
384
- ## apiKey — Restricting Registration
204
+ ## SSO Flow
385
205
 
386
- By default `POST /register` is open to the public. Set `apiKey` in your config to lock the endpoint:
206
+ ```
207
+ [User] → POST /auth/login (Sentri Auth Server)
208
+ ← { accessToken, user }
387
209
 
388
- ```typescript
389
- export const auth = createAuth({
390
- // ...
391
- apiKey: process.env.REGISTER_API_KEY!,
392
- });
210
+ [User] → GET /products (App A — client mode)
211
+ Authorization: Bearer <accessToken>
212
+ ← App A fetches public key from GET /auth/keys, verifies JWT
213
+ ← 200 OK
393
214
  ```
394
215
 
395
- Requests to `POST /register` must then include the header:
216
+ For SSO, use `algorithm: 'RS256'` on the server (or `createAuthServer()` which defaults to RS256). This automatically adds `GET /keys` to the auth router, which returns the public key in JWKS format (RFC 7517).
396
217
 
397
218
  ```
398
- X-Api-Key: <value of REGISTER_API_KEY>
219
+ GET /auth/keys → { keys: [{ kty, use, kid, n, e, ... }] }
399
220
  ```
400
221
 
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.
222
+ Client apps point `keyUri` at this endpoint and receive the public key automatically.
402
223
 
403
224
  ---
404
225
 
405
- ## Custom Route Handlers
406
-
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.
408
-
409
- ```typescript
410
- import { createAuth, SentriError } from 'sentri';
411
- import type { AuthResult } from 'sentri';
412
-
413
- export const auth = createAuth({
414
- secret: process.env.JWT_SECRET!,
415
- validRoles: ['user', 'admin'] as const,
416
- adapter: myAdapter,
417
-
418
- router: {
419
- // Add an OTP check before issuing tokens
420
- login: async (input): Promise<AuthResult> => {
421
- const otpVerified = await redis.get(`otp:${input.identifier}`);
422
- if (!otpVerified) {
423
- return { success: false, error: new SentriError('INVALID_CREDENTIALS', 'OTP required') };
424
- }
425
- return defaultLogin(input);
426
- },
427
-
428
- // Send a welcome email after registration
429
- register: async (input) => {
430
- const result = await defaultRegister(input);
431
- if (result.success) {
432
- await emailService.sendWelcome(input.identifier);
433
- }
434
- return result;
435
- },
436
- },
437
- });
438
- ```
226
+ ## Configuration
439
227
 
440
- ### Available handler signatures
228
+ ### `algorithm`
441
229
 
442
- | Key | Signature | Must return |
230
+ | Value | Type | Use case |
443
231
  |---|---|---|
444
- | `register` | `(input: RegisterInput) => Promise<RegisterResult>` | `RegisterResult` |
445
- | `login` | `(input: LoginInput) => Promise<AuthResult>` | `AuthResult` |
446
- | `refresh` | `(refreshToken: string) => Promise<RefreshResult>` | `RefreshResult` |
447
- | `logout` | `(refreshToken: string \| undefined) => Promise<void>` | `void` |
448
- | `logoutAll` | `(userId: string) => Promise<void>` | `void` |
449
- | `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
232
+ | `'HS256'` | Symmetric (default) | Single app, shared secret |
233
+ | `'RS256'` | Asymmetric | SSO enables `GET /keys` |
450
234
 
451
- ---
452
-
453
- ## Adapter Interface
235
+ When using RS256, `secret` must be a valid RSA private key in PEM format. `createAuthServer()` generates the key pair automatically.
454
236
 
455
- The adapter connects sentri to your database. Implement `AuthAdapter` for any ORM or data layer.
237
+ ### Cookie Strategy (SPA)
456
238
 
457
239
  ```typescript
458
- import type { AuthAdapter } from 'sentri';
459
-
460
- const adapter: AuthAdapter = {
461
- user: {
462
- findByIdentifier(identifier: string): Promise<UserRecord | null>,
463
- findById(id: string): Promise<UserRecord | null>,
464
- create(data: CreateUserData): Promise<{ id: string }>,
465
- updateRoles(userId: string, roles: string[]): Promise<void>,
466
- },
467
- session: {
468
- create(data: { userId: string; expiresAt: Date }): Promise<{ id: string }>,
469
- findById(sessionId: string): Promise<(SessionRecord & { user: UserRecord }) | null>,
470
- delete(sessionId: string): Promise<void>,
471
- deleteAllForUser(userId: string): Promise<void>,
472
- },
473
- };
240
+ createAuth({
241
+ mode: 'server',
242
+ cookie: { secure: true }, // httpOnly refresh token
243
+ accessCookie: { secure: true }, // non-httpOnly access token (readable by JS)
244
+ });
474
245
  ```
475
246
 
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.
247
+ After login, both cookies are set automatically. `protect()` reads the access token from the cookie when no `Authorization` header is present.
477
248
 
478
249
  ---
479
250
 
480
- ## Pre-built Router
481
-
482
- `auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
483
-
484
- ```typescript
485
- { error: boolean, statusCode: number, message: string, data: T | null }
486
- ```
487
-
488
- ### Endpoints
251
+ ## Endpoints
489
252
 
490
- #### `POST /register`
253
+ `auth.router()` mounts these endpoints:
491
254
 
492
- ```
493
- Headers: X-Api-Key: <key> (required when config.apiKey is set)
494
- Body: { identifier, password, roles?: string[] }
495
- Returns: { user: { id, identifier, roles } }
496
- Status: 201
497
- ```
498
-
499
- #### `POST /login`
255
+ | Method | Path | Auth | Description |
256
+ |---|---|---|---|
257
+ | `POST` | `/register` | — | Create a user (requires `X-Api-Key` when `apiKey` is set) |
258
+ | `POST` | `/login` | — | Authenticate, receive tokens |
259
+ | `POST` | `/refresh` | — | Rotate refresh token |
260
+ | `POST` | `/logout` | — | Invalidate current session |
261
+ | `POST` | `/logout-all` | ✓ | Invalidate all sessions |
262
+ | `GET` | `/me` | ✓ | Return authenticated user |
263
+ | `PATCH` | `/me/identifier` | ✓ self | Change identifier (username/email) |
264
+ | `PATCH` | `/me/password` | ✓ self | Change password — revokes all sessions |
265
+ | `POST` | `/users/:userId/roles` | ✓ admin | Assign roles to user |
266
+ | `GET` | `/keys` | — | Public key in JWKS format (RS256 only) |
500
267
 
501
- ```
502
- Body: { identifier, password }
503
- Returns: { accessToken, user: { id, identifier, roles } }
504
- Cookies: access_token=<jwt> (when config.accessCookie is set)
505
- refresh_token=<jwt> (httpOnly)
506
- Status: 200
268
+ All responses use the envelope:
269
+ ```json
270
+ { "error": false, "statusCode": 200, "message": "...", "data": { ... } }
507
271
  ```
508
272
 
509
- #### `POST /refresh`
273
+ ### Change Identifier
510
274
 
511
275
  ```
512
- Cookie: refresh_token=<token>
513
- Returns: { accessToken }
514
- Cookies: access_token=<new-jwt> (when config.accessCookie is set)
515
- refresh_token=<new-jwt> (rotated)
516
- Status: 200
517
- ```
518
-
519
- #### `POST /logout`
276
+ PATCH /me/identifier
277
+ Authorization: Bearer <token>
278
+ Content-Type: application/json
520
279
 
521
- ```
522
- Cookie: refresh_token=<token>
523
- Clears: access_token, refresh_token cookies
524
- Returns: null
525
- Status: 200
280
+ { "identifier": "new@example.com" }
526
281
  ```
527
282
 
528
- #### `POST /logout-all`
283
+ ### Change Password
529
284
 
530
285
  ```
531
- Headers: Authorization: Bearer <accessToken> (or access_token cookie)
532
- Clears: access_token, refresh_token cookies
533
- Returns: null
534
- Status: 200
535
- ```
286
+ PATCH /me/password
287
+ Authorization: Bearer <token>
288
+ Content-Type: application/json
536
289
 
537
- #### `GET /me`
538
-
539
- ```
540
- Headers: Authorization: Bearer <accessToken> (or access_token cookie)
541
- Returns: { id, identifier, roles }
542
- Status: 200
290
+ { "currentPassword": "old-pass", "newPassword": "new-pass" }
543
291
  ```
544
292
 
545
- #### `POST /users/:userId/roles`
546
-
547
- ```
548
- Headers: Authorization: Bearer <accessToken> (must have admin role)
549
- Body: { roles: string[] }
550
- Returns: { user: { id, identifier, roles } }
551
- Status: 200
552
- ```
293
+ Changing the password revokes all existing sessions. The user must log in again after this call.
553
294
 
554
295
  ---
555
296
 
@@ -557,53 +298,39 @@ Status: 200
557
298
 
558
299
  ### `auth.protect()`
559
300
 
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.
301
+ Verifies the JWT and sets `req.user`. In server mode, also performs silent token refresh when the access token expires.
566
302
 
567
303
  ```typescript
568
- router.get('/dashboard', auth.protect(), (request, response) => {
569
- response.json(request.user); // { id, identifier, roles }
570
- });
304
+ app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
571
305
  ```
572
306
 
573
- Returns HTTP 401 if:
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
577
-
578
- ---
307
+ Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
579
308
 
580
309
  ### `auth.authorize(...roles)`
581
310
 
582
- Enforces role-based access. Must be used **after** `auth.protect()`.
311
+ Role-based access must follow `protect()`.
583
312
 
584
313
  ```typescript
585
- router.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
314
+ app.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
586
315
  ```
587
316
 
588
- ---
589
-
590
317
  ### `auth.permit(check | options)`
591
318
 
592
- Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
319
+ Resource-level permission must follow `protect()`.
593
320
 
594
321
  ```typescript
595
322
  // Ownership check
596
- router.put('/users/:id', auth.protect(),
597
- auth.permit((req) => req.user!.id === req.params['id']),
323
+ app.put('/users/:id', auth.protect(),
324
+ auth.permit((req) => req.user!.id === req.params.id),
598
325
  handler,
599
326
  );
600
327
 
601
- // Role bypass + custom check
602
- router.delete('/posts/:id', auth.protect(),
328
+ // Role bypass + ownership check
329
+ app.delete('/posts/:id', auth.protect(),
603
330
  auth.permit({
604
331
  roles: ['admin'],
605
332
  check: async (req) => {
606
- const post = await db.post.findById(req.params['id']);
333
+ const post = await db.posts.findById(req.params.id);
607
334
  return post?.authorId === req.user!.id;
608
335
  },
609
336
  }),
@@ -613,140 +340,66 @@ router.delete('/posts/:id', auth.protect(),
613
340
 
614
341
  ---
615
342
 
616
- ### `createIdempotencyMiddleware(options?)`
343
+ ## Token Utilities
617
344
 
618
- Makes mutating operations idempotent via `X-Idempotency-Key` header. See [Request Idempotency](#request-idempotency).
619
-
620
- ---
621
-
622
- ## Programmatic API
623
-
624
- ### Token utilities
345
+ Available on `ServerAuthClient` only:
625
346
 
626
347
  ```typescript
627
- const accessToken = auth.signAccessToken({ id, identifier, roles });
628
- const user = auth.verifyAccessToken(accessToken); // throws SentriError if invalid
629
- const { sessionId } = auth.verifyRefreshToken(token); // throws SentriError if invalid
348
+ const auth = createAuth({ mode: 'server', ... });
349
+
350
+ // Sign
351
+ const accessToken = auth.signAccessToken({ id, identifier, roles });
630
352
  const refreshToken = auth.signRefreshToken(sessionId);
631
- ```
632
353
 
633
- ### Password utilities
354
+ // Verify
355
+ const user = auth.verifyAccessToken(accessToken);
356
+ const { sessionId } = auth.verifyRefreshToken(refreshToken);
634
357
 
635
- ```typescript
636
- const hash = await auth.hashPassword('secret123');
358
+ // Password
359
+ const hash = await auth.hashPassword('secret123');
637
360
  const valid = await auth.verifyPassword('secret123', hash);
638
- ```
639
-
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
361
 
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
- }
362
+ // Extract raw token from request
363
+ const token = auth.getCurrentAccessToken(req);
666
364
  ```
667
365
 
668
366
  ---
669
367
 
670
- ## Types
368
+ ## Error Handling
671
369
 
672
370
  ```typescript
673
- import type {
674
- AuthConfig,
675
- AuthClient,
676
- AuthAdapter,
677
- AuthHooks,
678
- AuthUser,
679
- AuthResult,
680
- RegisterResult,
681
- AssignRolesResult,
682
- RefreshResult,
683
- ApiResponse,
684
- RegisterInput,
685
- LoginInput,
686
- RouterHandlers,
687
- UserRecord,
688
- SessionRecord,
689
- CreateUserData,
690
- CookieConfig,
691
- AccessCookieConfig,
692
- IdempotencyOptions,
693
- PermitCheck,
694
- PermitOptions,
695
- SentriErrorCode,
696
- } from 'sentri';
697
-
698
- import {
699
- SentriError,
700
- AUTH_ERROR_STATUS,
701
- createAuth,
702
- register,
703
- createIdempotencyMiddleware,
704
- getCurrentAccessToken,
705
- } from 'sentri';
371
+ app.use('/auth', auth.router());
372
+ app.use('/api', apiRouter);
373
+ app.use(auth.errorHandler()); // must be last
706
374
  ```
707
375
 
708
- ---
709
-
710
- ## Error Handling
711
-
712
- All errors thrown by the library are instances of `SentriError` with a machine-readable `code` and an HTTP `statusCode`:
713
-
714
376
  | Code | HTTP | Meaning |
715
377
  |---|---|---|
716
378
  | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
717
- | `USER_ALREADY_EXISTS` | 409 | Registration with duplicate identifier |
718
- | `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
719
- | `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
720
- | `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
721
- | `UNAUTHORIZED` | 401 | No valid access token, or session not found |
379
+ | `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
380
+ | `USER_NOT_FOUND` | 404 | User does not exist |
381
+ | `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
382
+ | `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
383
+ | `UNAUTHORIZED` | 401 | No valid token or session not found |
722
384
  | `FORBIDDEN` | 403 | Authenticated but missing required role |
723
- | `INVALID_ROLE` | 400 | Role name not in `validRoles` |
724
- | `VALIDATION_ERROR` | 400 | Missing or invalid input field |
725
- | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` configuration |
726
-
727
- ### `auth.errorHandler()`
728
-
729
- Mount **after all your routes**:
730
-
731
- ```typescript
732
- app.use('/auth', auth.router());
733
- app.use('/api', apiRouter);
734
- app.use(auth.errorHandler());
735
- ```
385
+ | `INVALID_ROLE` | 400 | Role not in `validRoles` |
386
+ | `VALIDATION_ERROR` | 400 | Missing or invalid input |
387
+ | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` config |
736
388
 
737
389
  ### Extending `SentriError`
738
390
 
739
391
  ```typescript
740
392
  import { SentriError } from 'sentri';
741
393
 
742
- export class NotFoundError extends SentriError {
394
+ class NotFoundError extends SentriError {
743
395
  constructor(resource: string) {
744
396
  super('NOT_FOUND', `${resource} not found`, 404);
745
397
  }
746
398
  }
747
399
 
400
+ // Caught automatically by auth.errorHandler()
748
401
  app.get('/items/:id', auth.protect(), async (req, res) => {
749
- const item = await db.items.findById(req.params['id']);
402
+ const item = await db.items.findById(req.params.id);
750
403
  if (!item) throw new NotFoundError('Item');
751
404
  res.json(item);
752
405
  });
@@ -754,39 +407,62 @@ app.get('/items/:id', auth.protect(), async (req, res) => {
754
407
 
755
408
  ---
756
409
 
757
- ## Migration Guide
410
+ ## Request Idempotency
758
411
 
759
- ### Breaking changes in 2.0.0
412
+ Repeat requests with the same `X-Idempotency-Key` header receive the cached response immediately (2xx only). Useful for POST/PUT/PATCH endpoints where clients may retry on network failure.
760
413
 
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. |
414
+ ### Via `createAuthServer()` (recommended)
766
415
 
767
- ### New in 2.0.0
416
+ ```typescript
417
+ const auth = createAuthServer({
418
+ validRoles: ['user', 'admin'] as const,
419
+ db: { connectionString: process.env.DATABASE_URL! },
420
+ redisUrl: process.env.REDIS_URL, // omit for in-memory cache
421
+ });
768
422
 
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.
423
+ // Mount before your routes
424
+ app.use(auth.idempotencyMiddleware());
776
425
 
777
- ### Breaking changes in 1.1.0
426
+ // Override TTL or methods
427
+ app.use(auth.idempotencyMiddleware({ ttl: 60_000 }));
428
+ ```
778
429
 
779
- | What changed | Action required |
780
- |---|---|
781
- | `POST /signup` renamed to `POST /register` | Update all client-side calls |
782
- | `RouterHandlers.signup` renamed to `RouterHandlers.register` | Update config if you used a custom signup handler |
430
+ When `redisUrl` is set in server config, the middleware automatically uses Redis — no extra config needed.
431
+
432
+ ### Standalone (without createAuthServer)
433
+
434
+ ```typescript
435
+ import { createIdempotencyMiddleware } from 'sentri';
436
+
437
+ // In-memory (single process)
438
+ app.use(createIdempotencyMiddleware({ ttl: 300_000 }));
439
+
440
+ // Redis (multi-process)
441
+ app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
442
+ ```
443
+
444
+ ### Options
445
+
446
+ | Option | Default | Description |
447
+ |---|---|---|
448
+ | `ttl` | `300_000` | Cache TTL in milliseconds |
449
+ | `header` | `'X-Idempotency-Key'` | Header name to read the key from |
450
+ | `methods` | `['POST','PUT','PATCH']` | HTTP methods to apply idempotency to |
451
+ | `maxSize` | `10_000` | Max in-memory entries (ignored when `redisUrl` is set) |
452
+ | `redisUrl` | — | Redis connection URL for multi-process cache |
453
+
454
+ ---
455
+
456
+ ## Migration Guide
783
457
 
784
- ### Breaking changes in 1.2.0
458
+ ### 3.0.0 Breaking Changes
785
459
 
786
460
  | What changed | Action required |
787
461
  |---|---|
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 }` |
462
+ | `createAuth` now requires `mode: 'server'` or `mode: 'client'` | Add `mode: 'server'` to existing configs |
463
+ | `adapter` field removed Sentri owns the schema | Remove adapter, add `dialect` (Kysely Dialect) |
464
+ | `algorithm` now supports `'RS256' \| 'RS384' \| 'RS512'` | Optional HS256 still default |
465
+ | `AuthError` renamed to `SentriError` | Update imports: `import { SentriError } from 'sentri'` |
466
+ | `AUTH_ERROR_STATUS` renamed to `SENTRI_ERROR_STATUS` | Update references |
467
+ | `npx sentri generate` removed | Run `await auth.migrate()` at startup instead |
468
+ | Templates directory removed | No longer needed |