sentri 1.1.2 → 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.
Files changed (64) hide show
  1. package/README.md +268 -448
  2. package/dist/cli.d.ts +0 -2
  3. package/dist/cli.js +113 -107
  4. package/dist/index.d.ts +545 -11
  5. package/dist/index.js +1 -5
  6. package/package.json +9 -7
  7. package/dist/cli.d.ts.map +0 -1
  8. package/dist/cli.js.map +0 -1
  9. package/dist/client.d.ts +0 -160
  10. package/dist/client.d.ts.map +0 -1
  11. package/dist/client.js +0 -45
  12. package/dist/client.js.map +0 -1
  13. package/dist/errors/AuthError.d.ts +0 -99
  14. package/dist/errors/AuthError.d.ts.map +0 -1
  15. package/dist/errors/AuthError.js +0 -97
  16. package/dist/errors/AuthError.js.map +0 -1
  17. package/dist/index.d.ts.map +0 -1
  18. package/dist/index.js.map +0 -1
  19. package/dist/libs/config.d.ts +0 -62
  20. package/dist/libs/config.d.ts.map +0 -1
  21. package/dist/libs/config.js +0 -97
  22. package/dist/libs/config.js.map +0 -1
  23. package/dist/libs/hash.d.ts +0 -17
  24. package/dist/libs/hash.d.ts.map +0 -1
  25. package/dist/libs/hash.js +0 -22
  26. package/dist/libs/hash.js.map +0 -1
  27. package/dist/libs/token.d.ts +0 -46
  28. package/dist/libs/token.d.ts.map +0 -1
  29. package/dist/libs/token.js +0 -118
  30. package/dist/libs/token.js.map +0 -1
  31. package/dist/middleware/authorize.d.ts +0 -18
  32. package/dist/middleware/authorize.d.ts.map +0 -1
  33. package/dist/middleware/authorize.js +0 -30
  34. package/dist/middleware/authorize.js.map +0 -1
  35. package/dist/middleware/errorHandler.d.ts +0 -71
  36. package/dist/middleware/errorHandler.d.ts.map +0 -1
  37. package/dist/middleware/errorHandler.js +0 -74
  38. package/dist/middleware/errorHandler.js.map +0 -1
  39. package/dist/middleware/permit.d.ts +0 -62
  40. package/dist/middleware/permit.d.ts.map +0 -1
  41. package/dist/middleware/permit.js +0 -61
  42. package/dist/middleware/permit.js.map +0 -1
  43. package/dist/middleware/protect.d.ts +0 -31
  44. package/dist/middleware/protect.d.ts.map +0 -1
  45. package/dist/middleware/protect.js +0 -54
  46. package/dist/middleware/protect.js.map +0 -1
  47. package/dist/middleware/router.d.ts +0 -34
  48. package/dist/middleware/router.d.ts.map +0 -1
  49. package/dist/middleware/router.js +0 -264
  50. package/dist/middleware/router.js.map +0 -1
  51. package/dist/services/auth.d.ts +0 -85
  52. package/dist/services/auth.d.ts.map +0 -1
  53. package/dist/services/auth.js +0 -173
  54. package/dist/services/auth.js.map +0 -1
  55. package/dist/types/auth.d.ts +0 -450
  56. package/dist/types/auth.d.ts.map +0 -1
  57. package/dist/types/auth.js +0 -21
  58. package/dist/types/auth.js.map +0 -1
  59. package/templates/drizzle/adapter.ts +0 -154
  60. package/templates/drizzle/auth.ts +0 -82
  61. package/templates/drizzle/schema.ts +0 -47
  62. package/templates/prisma/adapter.ts +0 -122
  63. package/templates/prisma/auth.ts +0 -85
  64. package/templates/prisma/schema.prisma +0 -56
package/README.md CHANGED
@@ -1,6 +1,9 @@
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. 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,16 +11,16 @@ Auth and authorization library for Express + PostgreSQL. Provides JWT-based auth
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
- - [Custom Route Handlers](#custom-route-handlers)
14
- - [Adapter Interface](#adapter-interface)
15
- - [Pre-built Router](#pre-built-router)
18
+ - [Endpoints](#endpoints)
16
19
  - [Middleware](#middleware)
17
- - [Programmatic API](#programmatic-api)
18
- - [Types](#types)
20
+ - [Token Utilities](#token-utilities)
19
21
  - [Error Handling](#error-handling)
20
- - [Migration from 1.0.x](#migration-from-10x)
22
+ - [Request Idempotency](#request-idempotency)
23
+ - [Migration Guide](#migration-guide)
21
24
 
22
25
  ---
23
26
 
@@ -27,444 +30,308 @@ Auth and authorization library for Express + PostgreSQL. Provides JWT-based auth
27
30
  npm install sentri
28
31
  ```
29
32
 
30
- **Peer dependency:** `express >= 4.0.0`
31
-
32
- ---
33
-
34
- ## Quick Start
33
+ `kysely` and `pg` are bundled with sentri — no separate installation needed for PostgreSQL.
35
34
 
36
- ### 1. Generate templates
35
+ For other databases, install the driver:
37
36
 
38
37
  ```bash
39
- # Prisma
40
- npx sentri generate prisma
38
+ # MySQL
39
+ npm install mysql2
41
40
 
42
- # Drizzle
43
- npx sentri generate drizzle
41
+ # SQLite
42
+ npm install better-sqlite3
44
43
  ```
45
44
 
46
- This creates:
45
+ **Peer dependency:** `express >= 4.0.0`
47
46
 
48
- ```
49
- src/lib/
50
- index.ts ← barrel export
51
- sentri/
52
- adapter.ts ← AuthAdapter implementation
53
- auth.ts ← configured auth client
54
- schema.ts ← table definitions (Drizzle only)
55
- prisma/
56
- schema.prisma ← Prisma models (Prisma only, created or appended)
57
- ```
47
+ ---
48
+
49
+ ## Quick Start
58
50
 
59
- ### 2. Mount the router and error handler
51
+ ### Server Mode PostgreSQL (recommended)
60
52
 
61
53
  ```typescript
62
54
  import express from 'express';
63
- import { auth } from './lib/sentri/auth.js';
55
+ import { createAuthServer } from 'sentri';
56
+
57
+ const auth = createAuthServer({
58
+ validRoles: ['user', 'admin'] as const,
59
+ db: { connectionString: process.env.DATABASE_URL! },
60
+ });
64
61
 
65
62
  const app = express();
66
63
  app.use(express.json());
67
- app.use('/auth', auth.router());
64
+ app.use(auth.idempotencyMiddleware());
68
65
 
69
- // ... your routes ...
66
+ await auth.migrate();
67
+ app.use('/auth', auth.router());
70
68
 
71
- // Must be last catches SentriError from sentri and your own subclasses
69
+ app.get('/me', auth.protect(), (req, res) => res.json(req.user));
72
70
  app.use(auth.errorHandler());
73
71
  app.listen(3000);
74
72
  ```
75
73
 
76
- Done. All endpoints are available at `/auth/*`.
77
-
78
- ---
79
-
80
- ## CLI
81
-
82
- ### `sentri generate <prisma|drizzle>`
83
-
84
- Generates adapter, auth config, and schema templates in one command.
85
-
86
- ```bash
87
- npx sentri generate prisma
88
- npx sentri generate drizzle
89
- ```
90
-
91
- **What gets created:**
92
-
93
- | File | Behavior |
94
- |---|---|
95
- | `src/lib/sentri/adapter.ts` | Created fresh (error if exists) |
96
- | `src/lib/sentri/auth.ts` | Created fresh (error if exists) |
97
- | `src/lib/sentri/schema.ts` | Drizzle only — created fresh or tables appended |
98
- | `prisma/schema.prisma` | Prisma only — created fresh or models appended |
99
- | `src/lib/index.ts` | Created fresh, skipped if already exists |
100
-
101
- ---
74
+ `createAuthServer()` generates an RSA-2048 key pair at startup and enables `GET /keys` automatically.
102
75
 
103
- ## Configuration
76
+ ### Server Mode — Custom Dialect
104
77
 
105
78
  ```typescript
79
+ import express from 'express';
106
80
  import { createAuth } from 'sentri';
107
-
108
- export const auth = createAuth({
109
- secret: process.env.JWT_SECRET!, // required — keep in env, min 32 chars
110
- validRoles: ['user', 'admin'] as const, // required — use `as const` for type safety
111
- adapter: myAdapter, // required — see Adapter Interface
112
-
113
- // optional
114
- accessExpiresIn: '15m', // default: '15m'
115
- refreshExpiresIn: '7d', // default: '7d'
116
- algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
117
- saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
118
-
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.
121
- apiKey: process.env.REGISTER_API_KEY, // optional
122
-
123
- cookie: { // optional — enables httpOnly cookie for refresh token
124
- 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: '/'
129
- },
130
-
131
- // router: { // optional — replace built-in service logic per route
132
- // login: async (input) => { ... },
133
- // register: async (input) => { ... },
134
- // refresh: async (refreshToken) => { ... },
135
- // logout: async (refreshToken) => { ... },
136
- // logoutAll: async (userId) => { ... },
137
- // assignRoles: async (userId, roles) => { ... },
138
- // },
139
- });
140
- ```
141
-
142
- `accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'15m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
143
-
144
- When `cookie` is configured, the refresh token is stored in an httpOnly cookie automatically. No `cookie-parser` middleware is needed.
145
-
146
- ---
147
-
148
- ## apiKey — Restricting Registration
149
-
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`.
151
-
152
- Set `apiKey` in your config to lock the endpoint:
153
-
154
- ```typescript
155
- export const auth = createAuth({
156
- // ...
157
- apiKey: process.env.REGISTER_API_KEY!,
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,
158
90
  });
159
- ```
160
-
161
- Requests to `POST /register` must then include the header:
162
91
 
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);
163
98
  ```
164
- X-Api-Key: <value of REGISTER_API_KEY>
165
- ```
166
-
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.).
168
-
169
- ---
170
99
 
171
- ## Session-Bound Access Tokens
100
+ ### Client Mode (Other Apps)
172
101
 
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.
102
+ ```typescript
103
+ import express from 'express';
104
+ import { createAuth } from 'sentri';
174
105
 
175
- **What this means in practice:**
106
+ const auth = createAuth({
107
+ mode: 'client',
108
+ keyUri: 'https://auth.myapp.com/auth/keys',
109
+ });
176
110
 
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.
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);
180
114
 
115
+ app.use(auth.errorHandler());
181
116
  ```
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
186
- ```
187
-
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.
189
117
 
190
118
  ---
191
119
 
192
- ## Custom Route Handlers
120
+ ## Server Mode
193
121
 
194
- 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.
122
+ Server mode manages users, sessions, and tokens entirely within Sentri. The user provides a database dialect; Sentri handles the schema.
195
123
 
196
- Each key is optional only override what you need. Any key you omit falls back to the built-in behaviour.
124
+ ### `createAuthServer(options)`PostgreSQL shortcut
197
125
 
198
126
  ```typescript
199
- import { createAuth, SentriError } from 'sentri';
200
- import type { AuthResult } from 'sentri';
127
+ import { createAuthServer } from 'sentri';
201
128
 
202
- export const auth = createAuth({
203
- secret: process.env.JWT_SECRET!,
129
+ const auth = createAuthServer({
204
130
  validRoles: ['user', 'admin'] as const,
205
- adapter: myAdapter,
206
-
207
- router: {
208
- // Add an OTP check before issuing tokens
209
- login: async (input): Promise<AuthResult> => {
210
- const otpVerified = await redis.get(`otp:${input.identifier}`);
211
- if (!otpVerified) {
212
- return { success: false, error: new SentriError('INVALID_CREDENTIALS', 'OTP required') };
213
- }
214
- return defaultLogin(input);
215
- },
216
131
 
217
- // Send a welcome email after registration
218
- register: async (input) => {
219
- const result = await defaultRegister(input);
220
- if (result.success) {
221
- await emailService.sendWelcome(input.identifier);
222
- }
223
- return result;
224
- },
132
+ // Connection string
133
+ db: { connectionString: process.env.DATABASE_URL!, max: 10 },
225
134
 
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
- },
135
+ // or individual params —
136
+ // db: { host: 'localhost', port: 5432, database: 'mydb', user: 'app', password: 'secret' },
137
+
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
235
144
  });
236
145
  ```
237
146
 
238
- ### Available handler signatures
239
-
240
- | Key | Signature | Must return |
241
- |---|---|---|
242
- | `register` | `(input: RegisterInput) => Promise<RegisterResult>` | `RegisterResult` |
243
- | `login` | `(input: LoginInput) => Promise<AuthResult>` | `AuthResult` |
244
- | `refresh` | `(refreshToken: string) => Promise<RefreshResult>` | `RefreshResult` |
245
- | `logout` | `(refreshToken: string \| undefined) => Promise<void>` | `void` |
246
- | `logoutAll` | `(userId: string) => Promise<void>` | `void` |
247
- | `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
248
-
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
- ---
252
-
253
- ## Adapter Interface
254
-
255
- The adapter connects sentri to your database. Implement `AuthAdapter` for any ORM or data layer.
147
+ ### `createAuth(config)` Full config
256
148
 
257
149
  ```typescript
258
- import type { AuthAdapter } from 'sentri';
259
-
260
- const adapter: AuthAdapter = {
261
- user: {
262
- findByIdentifier(identifier: string): Promise<UserRecord | null>,
263
- findById(id: string): Promise<UserRecord | null>,
264
- create(data: CreateUserData): Promise<{ id: string }>,
265
- updateRoles(userId: string, roles: string[]): Promise<void>,
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,
266
169
  },
267
- session: {
268
- create(data: { userId: string; expiresAt: Date }): Promise<{ id: string }>,
269
- findById(sessionId: string): Promise<(SessionRecord & { user: UserRecord }) | null>,
270
- delete(sessionId: string): Promise<void>,
271
- deleteAllForUser(userId: string): Promise<void>,
272
- },
273
- };
170
+ });
274
171
  ```
275
172
 
276
- `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
-
278
- ### Using the generated adapter
279
-
280
- The generated `adapter.ts` exports `createAdapter(db)` so you can pass your existing database instance:
173
+ ### Database Migration
281
174
 
282
175
  ```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);
176
+ // Call once at startup — uses IF NOT EXISTS, safe to repeat
177
+ await auth.migrate();
295
178
  ```
296
179
 
297
- `createAdapter` throws `SentriError` with code `CONFIGURATION_ERROR` at runtime if called without a `db` argument.
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
298
183
 
299
184
  ---
300
185
 
301
- ## Pre-built Router
186
+ ## Client Mode
302
187
 
303
- `auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
188
+ Client mode has no database. It fetches the auth server's public key and validates JWTs stateless.
304
189
 
305
190
  ```typescript
306
- {
307
- error: boolean,
308
- statusCode: number,
309
- message: string,
310
- data: T | null
311
- }
191
+ createAuth({
192
+ mode: 'client',
193
+ keyUri: 'https://auth.myapp.com/auth/keys', // required
194
+ validRoles: ['admin', 'user'], // optional — TypeScript type safety only
195
+ });
312
196
  ```
313
197
 
314
- ### Endpoints
315
-
316
- #### `POST /register`
317
-
318
- Register a new user. Does **not** issue tokens — call `/login` after registration.
319
-
320
- ```
321
- Headers: X-Api-Key: <key> (required when config.apiKey is set)
322
- Body: { identifier, password, roles?: string[] }
323
- Returns: { user: { id, identifier, roles } }
324
- Status: 201
325
- ```
198
+ Available methods: `protect()`, `authorize()`, `permit()`, `errorHandler()`.
326
199
 
327
- `password` must be 8–72 characters. `roles` must be a subset of `validRoles`.
200
+ The public key is fetched once and cached for 1 hour. Token validation is fully stateless.
328
201
 
329
202
  ---
330
203
 
331
- #### `POST /login`
204
+ ## SSO Flow
332
205
 
333
- Authenticate a user and start a session.
334
-
335
- ```
336
- Body: { identifier, password }
337
- Returns: { accessToken, user: { id, identifier, roles } }
338
- Status: 200
339
206
  ```
207
+ [User] → POST /auth/login (Sentri Auth Server)
208
+ ← { accessToken, user }
340
209
 
341
- The refresh token is stored in an httpOnly cookie. The access token is returned in the response body.
342
-
343
- ---
344
-
345
- #### `POST /refresh`
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
214
+ ```
346
215
 
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.
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).
348
217
 
349
218
  ```
350
- Cookie: refresh_token=<token> (set automatically by /login)
351
- Returns: { accessToken }
352
- Status: 200
219
+ GET /auth/keys → { keys: [{ kty, use, kid, n, e, ... }] }
353
220
  ```
354
221
 
355
- The new refresh token is written back to the cookie. No body required.
222
+ Client apps point `keyUri` at this endpoint and receive the public key automatically.
356
223
 
357
224
  ---
358
225
 
359
- #### `POST /logout`
360
-
361
- Invalidate the current session.
362
-
363
- ```
364
- Cookie: refresh_token=<token>
365
- Returns: null
366
- Status: 200
367
- ```
226
+ ## Configuration
368
227
 
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.
228
+ ### `algorithm`
370
229
 
371
- ---
230
+ | Value | Type | Use case |
231
+ |---|---|---|
232
+ | `'HS256'` | Symmetric (default) | Single app, shared secret |
233
+ | `'RS256'` | Asymmetric | SSO — enables `GET /keys` |
372
234
 
373
- #### `POST /logout-all`
235
+ When using RS256, `secret` must be a valid RSA private key in PEM format. `createAuthServer()` generates the key pair automatically.
374
236
 
375
- Invalidate all sessions for the authenticated user (logout from every device).
237
+ ### Cookie Strategy (SPA)
376
238
 
377
- ```
378
- Headers: Authorization: Bearer <accessToken>
379
- Returns: null
380
- Status: 200
239
+ ```typescript
240
+ createAuth({
241
+ mode: 'server',
242
+ cookie: { secure: true }, // httpOnly refresh token
243
+ accessCookie: { secure: true }, // non-httpOnly access token (readable by JS)
244
+ });
381
245
  ```
382
246
 
383
- All access tokens across all devices are immediately rejected by `protect()` after this call.
247
+ After login, both cookies are set automatically. `protect()` reads the access token from the cookie when no `Authorization` header is present.
384
248
 
385
249
  ---
386
250
 
387
- #### `GET /me`
251
+ ## Endpoints
388
252
 
389
- Return the currently authenticated user.
253
+ `auth.router()` mounts these endpoints:
390
254
 
391
- ```
392
- Headers: Authorization: Bearer <accessToken>
393
- Returns: { id, identifier, roles }
394
- Status: 200
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) |
267
+
268
+ All responses use the envelope:
269
+ ```json
270
+ { "error": false, "statusCode": 200, "message": "...", "data": { ... } }
395
271
  ```
396
272
 
397
- ---
273
+ ### Change Identifier
398
274
 
399
- #### `POST /users/:userId/roles`
275
+ ```
276
+ PATCH /me/identifier
277
+ Authorization: Bearer <token>
278
+ Content-Type: application/json
279
+
280
+ { "identifier": "new@example.com" }
281
+ ```
400
282
 
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.
283
+ ### Change Password
402
284
 
403
285
  ```
404
- Headers: Authorization: Bearer <accessToken> (must have admin role)
405
- Body: { roles: string[] }
406
- Returns: { user: { id, identifier, roles } }
407
- Status: 200
286
+ PATCH /me/password
287
+ Authorization: Bearer <token>
288
+ Content-Type: application/json
289
+
290
+ { "currentPassword": "old-pass", "newPassword": "new-pass" }
408
291
  ```
409
292
 
293
+ Changing the password revokes all existing sessions. The user must log in again after this call.
294
+
410
295
  ---
411
296
 
412
297
  ## Middleware
413
298
 
414
299
  ### `auth.protect()`
415
300
 
416
- Verifies the `Authorization: Bearer <token>` header, confirms the session is still active in the database, and injects `request.user` into the request.
301
+ Verifies the JWT and sets `req.user`. In server mode, also performs silent token refresh when the access token expires.
417
302
 
418
303
  ```typescript
419
- router.get('/dashboard', auth.protect(), (request, response) => {
420
- response.json(request.user); // { id, identifier, roles }
421
- });
304
+ app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
422
305
  ```
423
306
 
424
- 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)
428
-
429
- ---
307
+ Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
430
308
 
431
309
  ### `auth.authorize(...roles)`
432
310
 
433
- Enforces role-based access. Must be used **after** `auth.protect()`. Passes if the user has at least one of the specified roles.
311
+ Role-based access must follow `protect()`.
434
312
 
435
313
  ```typescript
436
- router.delete(
437
- '/posts/:id',
438
- auth.protect(),
439
- auth.authorize('admin'),
440
- handler,
441
- );
314
+ app.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
442
315
  ```
443
316
 
444
- ---
445
-
446
317
  ### `auth.permit(check | options)`
447
318
 
448
- Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
319
+ Resource-level permission must follow `protect()`.
449
320
 
450
321
  ```typescript
451
- // Simple ownership check
452
- router.put(
453
- '/users/:id',
454
- auth.protect(),
455
- auth.permit((request) => request.user!.id === request.params['id']),
322
+ // Ownership check
323
+ app.put('/users/:id', auth.protect(),
324
+ auth.permit((req) => req.user!.id === req.params.id),
456
325
  handler,
457
326
  );
458
327
 
459
- // Admins bypass the check; others must own the resource
460
- router.delete(
461
- '/posts/:id',
462
- auth.protect(),
328
+ // Role bypass + ownership check
329
+ app.delete('/posts/:id', auth.protect(),
463
330
  auth.permit({
464
331
  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;
332
+ check: async (req) => {
333
+ const post = await db.posts.findById(req.params.id);
334
+ return post?.authorId === req.user!.id;
468
335
  },
469
336
  }),
470
337
  handler,
@@ -473,176 +340,129 @@ router.delete(
473
340
 
474
341
  ---
475
342
 
476
- ## Programmatic API
343
+ ## Token Utilities
477
344
 
478
- Token and password utilities are available on the auth client for use outside the built-in router.
345
+ Available on `ServerAuthClient` only:
479
346
 
480
347
  ```typescript
481
- // Token utilities
482
- const accessToken = auth.signAccessToken({ id, identifier, roles });
483
- const user = auth.verifyAccessToken(accessToken); // throws SentriError if invalid
484
- const { sessionId } = auth.verifyRefreshToken(token); // throws SentriError if invalid
485
- const refreshToken = auth.signRefreshToken(sessionId);
348
+ const auth = createAuth({ mode: 'server', ... });
486
349
 
487
- // Password utilities
488
- const hash = await auth.hashPassword('secret123');
489
- const valid = await auth.verifyPassword('secret123', hash);
490
- ```
491
-
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.
493
-
494
- ### `register` — standalone service function
495
-
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):
350
+ // Sign
351
+ const accessToken = auth.signAccessToken({ id, identifier, roles });
352
+ const refreshToken = auth.signRefreshToken(sessionId);
497
353
 
498
- ```typescript
499
- import { register } from 'sentri';
354
+ // Verify
355
+ const user = auth.verifyAccessToken(accessToken);
356
+ const { sessionId } = auth.verifyRefreshToken(refreshToken);
500
357
 
501
- const result = await register(
502
- { identifier: 'alice@example.com', password: 'hunter2', roles: ['user'] },
503
- config,
504
- );
358
+ // Password
359
+ const hash = await auth.hashPassword('secret123');
360
+ const valid = await auth.verifyPassword('secret123', hash);
505
361
 
506
- if (result.success) {
507
- console.log(result.user.id);
508
- } else {
509
- console.error(result.error.code); // 'USER_ALREADY_EXISTS' | 'INVALID_ROLE'
510
- }
362
+ // Extract raw token from request
363
+ const token = auth.getCurrentAccessToken(req);
511
364
  ```
512
365
 
513
366
  ---
514
367
 
515
- ## Types
368
+ ## Error Handling
516
369
 
517
370
  ```typescript
518
- import type {
519
- AuthConfig,
520
- AuthClient,
521
- AuthAdapter,
522
- AuthUser,
523
- AuthResult,
524
- RegisterResult,
525
- AssignRolesResult,
526
- RefreshResult,
527
- ApiResponse,
528
- RegisterInput,
529
- LoginInput,
530
- RouterHandlers,
531
- UserRecord,
532
- SessionRecord,
533
- CreateUserData,
534
- CookieConfig,
535
- PermitCheck,
536
- PermitOptions,
537
- SentriErrorCode,
538
- } from 'sentri';
539
-
540
- import { SentriError, AUTH_ERROR_STATUS, createAuth, register } from 'sentri';
371
+ app.use('/auth', auth.router());
372
+ app.use('/api', apiRouter);
373
+ app.use(auth.errorHandler()); // must be last
541
374
  ```
542
375
 
543
- ---
544
-
545
- ## Error Handling
546
-
547
- All errors thrown by the library are instances of `SentriError` with a machine-readable `code` and an HTTP `statusCode`:
548
-
549
376
  | Code | HTTP | Meaning |
550
377
  |---|---|---|
551
378
  | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
552
- | `USER_ALREADY_EXISTS` | 409 | Registration with duplicate identifier |
553
- | `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
554
- | `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
555
- | `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
556
- | `UNAUTHORIZED` | 401 | No valid access token, revoked session, or invalid API key |
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 |
557
384
  | `FORBIDDEN` | 403 | Authenticated but missing required role |
558
- | `INVALID_ROLE` | 400 | Role name not in `validRoles` |
559
- | `VALIDATION_ERROR` | 400 | Missing or invalid input field |
560
- | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` configuration |
561
-
562
- ### `auth.errorHandler()`
563
-
564
- Mount `auth.errorHandler()` **after all your routes** to automatically format every `SentriError` (and any subclass) into the standard envelope:
565
-
566
- ```typescript
567
- app.use('/auth', auth.router());
568
- app.use('/api', apiRouter);
569
-
570
- // Must be last
571
- app.use(auth.errorHandler());
572
- ```
573
-
574
- Optional logger for unexpected errors:
575
-
576
- ```typescript
577
- app.use(auth.errorHandler({
578
- onUnhandled: (err) => logger.error('Unexpected error', { err }),
579
- }));
580
- ```
385
+ | `INVALID_ROLE` | 400 | Role not in `validRoles` |
386
+ | `VALIDATION_ERROR` | 400 | Missing or invalid input |
387
+ | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` config |
581
388
 
582
389
  ### Extending `SentriError`
583
390
 
584
- Define application-specific errors by extending `SentriError`. They are caught automatically by `auth.errorHandler()` via `instanceof`:
585
-
586
391
  ```typescript
587
392
  import { SentriError } from 'sentri';
588
393
 
589
- export class NotFoundError extends SentriError {
394
+ class NotFoundError extends SentriError {
590
395
  constructor(resource: string) {
591
396
  super('NOT_FOUND', `${resource} not found`, 404);
592
397
  }
593
398
  }
594
399
 
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
400
+ // Caught automatically by auth.errorHandler()
602
401
  app.get('/items/:id', auth.protect(), async (req, res) => {
603
- const item = await db.items.findById(req.params['id']);
402
+ const item = await db.items.findById(req.params.id);
604
403
  if (!item) throw new NotFoundError('Item');
605
404
  res.json(item);
606
405
  });
607
406
  ```
608
407
 
609
- Response shape for any `SentriError` (built-in or custom):
408
+ ---
610
409
 
611
- ```json
612
- {
613
- "error": true,
614
- "statusCode": 404,
615
- "code": "NOT_FOUND",
616
- "message": "Item not found",
617
- "data": null
618
- }
410
+ ## Request Idempotency
411
+
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.
413
+
414
+ ### Via `createAuthServer()` (recommended)
415
+
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
+ });
422
+
423
+ // Mount before your routes
424
+ app.use(auth.idempotencyMiddleware());
425
+
426
+ // Override TTL or methods
427
+ app.use(auth.idempotencyMiddleware({ ttl: 60_000 }));
619
428
  ```
620
429
 
621
- ---
430
+ When `redisUrl` is set in server config, the middleware automatically uses Redis — no extra config needed.
622
431
 
623
- ## Migration from 1.0.x
432
+ ### Standalone (without createAuthServer)
624
433
 
625
- ### Breaking changes in 1.1.0
434
+ ```typescript
435
+ import { createIdempotencyMiddleware } from 'sentri';
626
436
 
627
- | What changed | Action required |
628
- |---|---|
629
- | `POST /signup` renamed to `POST /register` | Update all client-side calls |
630
- | `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 |
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
632
457
 
633
- ### Breaking changes in 1.2.0
458
+ ### 3.0.0 Breaking Changes
634
459
 
635
460
  | What changed | Action required |
636
461
  |---|---|
637
- | `AuthError` renamed to `SentriError` | Replace all `import { AuthError }` with `import { SentriError }` |
638
- | `AuthErrorCode` renamed to `SentriErrorCode` | Replace all `AuthErrorCode` type references |
639
- | `SignupResult` renamed to `RegisterResult` | Replace type references |
640
- | `SignupInput` renamed to `RegisterInput` | Replace type references |
641
- | `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.
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 |