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 +250 -574
- package/dist/cli.js +112 -13
- package/dist/index.d.ts +253 -754
- package/dist/index.js +1 -1
- package/package.json +7 -12
- package/templates/drizzle/adapter.ts +0 -154
- package/templates/drizzle/auth.ts +0 -125
- package/templates/drizzle/schema.ts +0 -47
- package/templates/prisma/adapter.ts +0 -122
- package/templates/prisma/auth.ts +0 -128
- 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
|
|
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
|
-
- [
|
|
14
|
+
- [Server Mode](#server-mode)
|
|
15
|
+
- [Client Mode](#client-mode)
|
|
16
|
+
- [SSO Flow](#sso-flow)
|
|
12
17
|
- [Configuration](#configuration)
|
|
13
|
-
- [
|
|
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
|
-
- [
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
## Quick Start
|
|
33
|
+
`kysely` and `pg` are bundled with sentri — no separate installation needed for PostgreSQL.
|
|
42
34
|
|
|
43
|
-
|
|
35
|
+
For other databases, install the driver:
|
|
44
36
|
|
|
45
37
|
```bash
|
|
46
|
-
#
|
|
47
|
-
|
|
38
|
+
# MySQL
|
|
39
|
+
npm install mysql2
|
|
48
40
|
|
|
49
|
-
#
|
|
50
|
-
|
|
41
|
+
# SQLite
|
|
42
|
+
npm install better-sqlite3
|
|
51
43
|
```
|
|
52
44
|
|
|
53
|
-
|
|
45
|
+
**Peer dependency:** `express >= 4.0.0`
|
|
54
46
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
###
|
|
51
|
+
### Server Mode — PostgreSQL (recommended)
|
|
67
52
|
|
|
68
53
|
```typescript
|
|
69
54
|
import express from 'express';
|
|
70
|
-
import {
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
app.use(createIdempotencyMiddleware());
|
|
78
|
-
|
|
66
|
+
await auth.migrate();
|
|
79
67
|
app.use('/auth', auth.router());
|
|
80
68
|
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
###
|
|
100
|
+
### Client Mode (Other Apps)
|
|
213
101
|
|
|
214
102
|
```typescript
|
|
215
|
-
import
|
|
103
|
+
import express from 'express';
|
|
104
|
+
import { createAuth } from 'sentri';
|
|
216
105
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
122
|
+
Server mode manages users, sessions, and tokens entirely within Sentri. The user provides a database dialect; Sentri handles the schema.
|
|
280
123
|
|
|
281
|
-
`
|
|
124
|
+
### `createAuthServer(options)` — PostgreSQL shortcut
|
|
282
125
|
|
|
283
126
|
```typescript
|
|
284
|
-
import {
|
|
127
|
+
import { createAuthServer } from 'sentri';
|
|
285
128
|
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
// — or individual params —
|
|
136
|
+
// db: { host: 'localhost', port: 5432, database: 'mydb', user: 'app', password: 'secret' },
|
|
306
137
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
###
|
|
173
|
+
### Database Migration
|
|
348
174
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
186
|
+
## Client Mode
|
|
360
187
|
|
|
361
|
-
|
|
188
|
+
Client mode has no database. It fetches the auth server's public key and validates JWTs stateless.
|
|
362
189
|
|
|
363
190
|
```typescript
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
198
|
+
Available methods: `protect()`, `authorize()`, `permit()`, `errorHandler()`.
|
|
372
199
|
|
|
373
|
-
|
|
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
|
-
##
|
|
204
|
+
## SSO Flow
|
|
385
205
|
|
|
386
|
-
|
|
206
|
+
```
|
|
207
|
+
[User] → POST /auth/login (Sentri Auth Server)
|
|
208
|
+
← { accessToken, user }
|
|
387
209
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
+
GET /auth/keys → { keys: [{ kty, use, kid, n, e, ... }] }
|
|
399
220
|
```
|
|
400
221
|
|
|
401
|
-
|
|
222
|
+
Client apps point `keyUri` at this endpoint and receive the public key automatically.
|
|
402
223
|
|
|
403
224
|
---
|
|
404
225
|
|
|
405
|
-
##
|
|
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
|
-
###
|
|
228
|
+
### `algorithm`
|
|
441
229
|
|
|
442
|
-
|
|
|
230
|
+
| Value | Type | Use case |
|
|
443
231
|
|---|---|---|
|
|
444
|
-
| `
|
|
445
|
-
| `
|
|
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
|
-
|
|
237
|
+
### Cookie Strategy (SPA)
|
|
456
238
|
|
|
457
239
|
```typescript
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
253
|
+
`auth.router()` mounts these endpoints:
|
|
491
254
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
273
|
+
### Change Identifier
|
|
510
274
|
|
|
511
275
|
```
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
283
|
+
### Change Password
|
|
529
284
|
|
|
530
285
|
```
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
Status: 200
|
|
535
|
-
```
|
|
286
|
+
PATCH /me/password
|
|
287
|
+
Authorization: Bearer <token>
|
|
288
|
+
Content-Type: application/json
|
|
536
289
|
|
|
537
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
311
|
+
Role-based access — must follow `protect()`.
|
|
583
312
|
|
|
584
313
|
```typescript
|
|
585
|
-
|
|
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
|
|
319
|
+
Resource-level permission — must follow `protect()`.
|
|
593
320
|
|
|
594
321
|
```typescript
|
|
595
322
|
// Ownership check
|
|
596
|
-
|
|
597
|
-
auth.permit((req) => req.user!.id === req.params
|
|
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 +
|
|
602
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
+
## Token Utilities
|
|
617
344
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
---
|
|
621
|
-
|
|
622
|
-
## Programmatic API
|
|
623
|
-
|
|
624
|
-
### Token utilities
|
|
345
|
+
Available on `ServerAuthClient` only:
|
|
625
346
|
|
|
626
347
|
```typescript
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
354
|
+
// Verify
|
|
355
|
+
const user = auth.verifyAccessToken(accessToken);
|
|
356
|
+
const { sessionId } = auth.verifyRefreshToken(refreshToken);
|
|
634
357
|
|
|
635
|
-
|
|
636
|
-
const hash
|
|
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
|
-
|
|
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
|
-
##
|
|
368
|
+
## Error Handling
|
|
671
369
|
|
|
672
370
|
```typescript
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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 |
|
|
718
|
-
| `USER_NOT_FOUND` | 404 |
|
|
719
|
-
| `TOKEN_EXPIRED` | 401 | JWT `exp`
|
|
720
|
-
| `TOKEN_INVALID` | 401 |
|
|
721
|
-
| `UNAUTHORIZED` | 401 | No valid
|
|
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
|
|
724
|
-
| `VALIDATION_ERROR` | 400 | Missing or invalid input
|
|
725
|
-
| `CONFIGURATION_ERROR` | 500 | Invalid `createAuth`
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
410
|
+
## Request Idempotency
|
|
758
411
|
|
|
759
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
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
|
-
|
|
426
|
+
// Override TTL or methods
|
|
427
|
+
app.use(auth.idempotencyMiddleware({ ttl: 60_000 }));
|
|
428
|
+
```
|
|
778
429
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
###
|
|
458
|
+
### 3.0.0 Breaking Changes
|
|
785
459
|
|
|
786
460
|
| What changed | Action required |
|
|
787
461
|
|---|---|
|
|
788
|
-
| `
|
|
789
|
-
| `
|
|
790
|
-
| `
|
|
791
|
-
| `
|
|
792
|
-
| `
|
|
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 |
|