sentri 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +388 -180
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +10 -103
- package/dist/index.d.ts +1046 -10
- package/dist/index.js +1 -4
- package/package.json +13 -6
- package/templates/drizzle/auth.ts +50 -7
- package/templates/prisma/auth.ts +50 -7
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.d.ts +0 -160
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -45
- package/dist/client.js.map +0 -1
- package/dist/errors/AuthError.d.ts +0 -101
- package/dist/errors/AuthError.d.ts.map +0 -1
- package/dist/errors/AuthError.js +0 -99
- package/dist/errors/AuthError.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/libs/config.d.ts +0 -62
- package/dist/libs/config.d.ts.map +0 -1
- package/dist/libs/config.js +0 -97
- package/dist/libs/config.js.map +0 -1
- package/dist/libs/hash.d.ts +0 -17
- package/dist/libs/hash.d.ts.map +0 -1
- package/dist/libs/hash.js +0 -22
- package/dist/libs/hash.js.map +0 -1
- package/dist/libs/token.d.ts +0 -46
- package/dist/libs/token.d.ts.map +0 -1
- package/dist/libs/token.js +0 -118
- package/dist/libs/token.js.map +0 -1
- package/dist/middleware/authorize.d.ts +0 -18
- package/dist/middleware/authorize.d.ts.map +0 -1
- package/dist/middleware/authorize.js +0 -30
- package/dist/middleware/authorize.js.map +0 -1
- package/dist/middleware/errorHandler.d.ts +0 -73
- package/dist/middleware/errorHandler.d.ts.map +0 -1
- package/dist/middleware/errorHandler.js +0 -76
- package/dist/middleware/errorHandler.js.map +0 -1
- package/dist/middleware/permit.d.ts +0 -62
- package/dist/middleware/permit.d.ts.map +0 -1
- package/dist/middleware/permit.js +0 -61
- package/dist/middleware/permit.js.map +0 -1
- package/dist/middleware/protect.d.ts +0 -31
- package/dist/middleware/protect.d.ts.map +0 -1
- package/dist/middleware/protect.js +0 -54
- package/dist/middleware/protect.js.map +0 -1
- package/dist/middleware/router.d.ts +0 -34
- package/dist/middleware/router.d.ts.map +0 -1
- package/dist/middleware/router.js +0 -264
- package/dist/middleware/router.js.map +0 -1
- package/dist/services/auth.d.ts +0 -85
- package/dist/services/auth.d.ts.map +0 -1
- package/dist/services/auth.js +0 -173
- package/dist/services/auth.js.map +0 -1
- package/dist/types/auth.d.ts +0 -450
- package/dist/types/auth.d.ts.map +0 -1
- package/dist/types/auth.js +0 -21
- package/dist/types/auth.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# sentri
|
|
2
2
|
|
|
3
|
-
Auth and authorization library for Express + PostgreSQL. Provides JWT
|
|
3
|
+
Auth and authorization library for Express + PostgreSQL. Provides stateless JWT authentication, automatic token refresh, cookie-based token storage, request idempotency, role-based access control, fine-grained permission checks, and a pre-built Express router — all behind a single typed client.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -10,6 +10,13 @@ Auth and authorization library for Express + PostgreSQL. Provides JWT-based auth
|
|
|
10
10
|
- [Quick Start](#quick-start)
|
|
11
11
|
- [CLI](#cli)
|
|
12
12
|
- [Configuration](#configuration)
|
|
13
|
+
- [Token Storage](#token-storage)
|
|
14
|
+
- [Stateless Access Tokens](#stateless-access-tokens)
|
|
15
|
+
- [Automatic Token Refresh](#automatic-token-refresh)
|
|
16
|
+
- [Request Idempotency](#request-idempotency)
|
|
17
|
+
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
18
|
+
- [Immediate Token Revocation](#immediate-token-revocation-istokenrevoked)
|
|
19
|
+
- [apiKey — Restricting Registration](#apikey--restricting-registration)
|
|
13
20
|
- [Custom Route Handlers](#custom-route-handlers)
|
|
14
21
|
- [Adapter Interface](#adapter-interface)
|
|
15
22
|
- [Pre-built Router](#pre-built-router)
|
|
@@ -17,6 +24,7 @@ Auth and authorization library for Express + PostgreSQL. Provides JWT-based auth
|
|
|
17
24
|
- [Programmatic API](#programmatic-api)
|
|
18
25
|
- [Types](#types)
|
|
19
26
|
- [Error Handling](#error-handling)
|
|
27
|
+
- [Migration Guide](#migration-guide)
|
|
20
28
|
|
|
21
29
|
---
|
|
22
30
|
|
|
@@ -55,15 +63,26 @@ prisma/
|
|
|
55
63
|
schema.prisma ← Prisma models (Prisma only, created or appended)
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
### 2. Mount the router
|
|
66
|
+
### 2. Mount the router and error handler
|
|
59
67
|
|
|
60
68
|
```typescript
|
|
61
69
|
import express from 'express';
|
|
62
70
|
import { auth } from './lib/sentri/auth.js';
|
|
71
|
+
import { createIdempotencyMiddleware } from 'sentri';
|
|
63
72
|
|
|
64
73
|
const app = express();
|
|
65
74
|
app.use(express.json());
|
|
75
|
+
|
|
76
|
+
// Optional: make POST/PUT/PATCH operations idempotent
|
|
77
|
+
app.use(createIdempotencyMiddleware());
|
|
78
|
+
|
|
66
79
|
app.use('/auth', auth.router());
|
|
80
|
+
|
|
81
|
+
// ... your routes ...
|
|
82
|
+
|
|
83
|
+
// Must be last — catches SentriError from sentri and your own subclasses
|
|
84
|
+
app.use(auth.errorHandler());
|
|
85
|
+
app.listen(3000);
|
|
67
86
|
```
|
|
68
87
|
|
|
69
88
|
Done. All endpoints are available at `/auth/*`.
|
|
@@ -104,23 +123,43 @@ export const auth = createAuth({
|
|
|
104
123
|
adapter: myAdapter, // required — see Adapter Interface
|
|
105
124
|
|
|
106
125
|
// optional
|
|
107
|
-
accessExpiresIn: '
|
|
126
|
+
accessExpiresIn: '5m', // default: '15m' — short is safe: auto-refresh handles it
|
|
108
127
|
refreshExpiresIn: '7d', // default: '7d'
|
|
109
128
|
algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
|
|
110
129
|
saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
|
|
111
130
|
|
|
112
|
-
//
|
|
113
|
-
// When set, only requests with this exact key can create new accounts.
|
|
131
|
+
// API key for POST /register — see apiKey section
|
|
114
132
|
apiKey: process.env.REGISTER_API_KEY, // optional
|
|
115
133
|
|
|
116
|
-
|
|
134
|
+
// Access token cookie — non-httpOnly so browser JS can read it.
|
|
135
|
+
// When set, protect() reads from this cookie when no Authorization header is present.
|
|
136
|
+
accessCookie: {
|
|
137
|
+
secure: process.env.NODE_ENV === 'production',
|
|
138
|
+
// name: 'access_token', // default: 'access_token'
|
|
139
|
+
// sameSite: 'strict', // default: 'strict'
|
|
140
|
+
// path: '/', // default: '/'
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Refresh token cookie — httpOnly (not readable by JS).
|
|
144
|
+
cookie: {
|
|
117
145
|
secure: process.env.NODE_ENV === 'production',
|
|
118
|
-
// name: 'refresh_token',
|
|
119
|
-
// httpOnly: true,
|
|
120
|
-
// sameSite: 'strict',
|
|
121
|
-
// path: '/',
|
|
146
|
+
// name: 'refresh_token', // default: 'refresh_token'
|
|
147
|
+
// httpOnly: true, // default: true
|
|
148
|
+
// sameSite: 'strict', // default: 'strict'
|
|
149
|
+
// path: '/', // default: '/'
|
|
122
150
|
},
|
|
123
151
|
|
|
152
|
+
// Lifecycle hooks — fire-and-forget, rejections are swallowed.
|
|
153
|
+
// hooks: {
|
|
154
|
+
// onLogin: async (user) => auditLog.record('login', user.id),
|
|
155
|
+
// onFailedLogin: (identifier) => rateLimiter.hit(`login:${identifier}`),
|
|
156
|
+
// onLogout: async (userId) => cache.invalidate(userId),
|
|
157
|
+
// },
|
|
158
|
+
|
|
159
|
+
// Optional immediate revocation — called on every protect() invocation.
|
|
160
|
+
// isTokenRevoked: async (sessionId) =>
|
|
161
|
+
// await redis.sismember('revoked_sessions', sessionId),
|
|
162
|
+
|
|
124
163
|
// router: { // optional — replace built-in service logic per route
|
|
125
164
|
// login: async (input) => { ... },
|
|
126
165
|
// register: async (input) => { ... },
|
|
@@ -132,53 +171,234 @@ export const auth = createAuth({
|
|
|
132
171
|
});
|
|
133
172
|
```
|
|
134
173
|
|
|
135
|
-
`accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'
|
|
174
|
+
`accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'5m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Token Storage
|
|
179
|
+
|
|
180
|
+
sentri uses a two-cookie strategy for SPAs:
|
|
181
|
+
|
|
182
|
+
| Cookie | `httpOnly` | Readable by JS | Purpose |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| `access_token` | **false** | ✅ Yes | Carries the JWT for authenticated requests |
|
|
185
|
+
| `refresh_token` | **true** | ❌ No | Used to silently rotate the access token |
|
|
186
|
+
|
|
187
|
+
### Setting up cookie storage
|
|
188
|
+
|
|
189
|
+
Enable both cookies in your config:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
export const auth = createAuth({
|
|
193
|
+
accessExpiresIn: '5m',
|
|
194
|
+
accessCookie: { secure: process.env.NODE_ENV === 'production' },
|
|
195
|
+
cookie: { secure: process.env.NODE_ENV === 'production' },
|
|
196
|
+
// ...
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
After `/login`, the router sets both cookies automatically. `protect()` reads the access token from the cookie when no `Authorization: Bearer` header is present, so your SPA needs zero manual header management.
|
|
136
201
|
|
|
137
|
-
|
|
202
|
+
### Reading the access token in the browser
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
// Read from document.cookie
|
|
206
|
+
const token = document.cookie
|
|
207
|
+
.split('; ')
|
|
208
|
+
.find(c => c.startsWith('access_token='))
|
|
209
|
+
?.split('=')[1];
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Reading the token server-side
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { getCurrentAccessToken } from 'sentri';
|
|
216
|
+
|
|
217
|
+
app.get('/debug', (req, res) => {
|
|
218
|
+
// Checks Authorization header first, then access_token cookie
|
|
219
|
+
const token = getCurrentAccessToken(req, config);
|
|
220
|
+
res.json({ hasToken: !!token });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Or via the auth client (pre-bound to config):
|
|
224
|
+
const token = auth.getCurrentAccessToken(req);
|
|
225
|
+
```
|
|
138
226
|
|
|
139
227
|
---
|
|
140
228
|
|
|
141
|
-
##
|
|
229
|
+
## Stateless Access Tokens
|
|
230
|
+
|
|
231
|
+
Access tokens are validated **stateless** — by JWT signature and `exp` claim only. No database lookup occurs on every protected request, which keeps `protect()` fast regardless of load.
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
Request → protect() verifies JWT signature + expiry → ✓ allowed
|
|
235
|
+
(no DB round-trip per request)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Trade-off:** After logout, an access token remains technically valid until it expires (`accessExpiresIn`). Keep this window short (`'5m'`) to limit exposure. Refresh tokens are still session-bound, so a revoked refresh token cannot obtain new access tokens.
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
Logout → session deleted from DB
|
|
242
|
+
Request with old access token → accepted until exp (≤ 5m)
|
|
243
|
+
Request with old refresh token → rejected immediately (session gone)
|
|
244
|
+
```
|
|
142
245
|
|
|
143
|
-
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Automatic Token Refresh
|
|
249
|
+
|
|
250
|
+
When `protect()` encounters an expired access token, it performs one silent refresh before returning an error:
|
|
144
251
|
|
|
145
|
-
|
|
252
|
+
1. Reads the refresh token from the httpOnly cookie.
|
|
253
|
+
2. Rotates the session (deletes old, creates new).
|
|
254
|
+
3. Issues new access + refresh tokens.
|
|
255
|
+
4. Updates both cookies on the response.
|
|
256
|
+
5. Writes the new access token to the `X-New-Access-Token` response header.
|
|
257
|
+
6. Calls `next()` — the handler runs normally without any knowledge of the refresh.
|
|
258
|
+
|
|
259
|
+
If the refresh also fails (session expired or revoked), the middleware calls `next(SentriError)` with `UNAUTHORIZED` and the user must log in again.
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
Request with expired access token
|
|
263
|
+
→ protect() tries refresh cookie
|
|
264
|
+
→ refresh succeeds → new tokens set → request continues transparently
|
|
265
|
+
→ refresh fails → 401 UNAUTHORIZED — user must login
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Client-side:** Check the `X-New-Access-Token` header on every response to detect a silent refresh and update any in-memory token copy:
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
const newToken = response.headers.get('X-New-Access-Token');
|
|
272
|
+
if (newToken) currentAccessToken = newToken;
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
When `accessCookie` is configured the cookie is updated automatically, so in-memory management is optional.
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Request Idempotency
|
|
280
|
+
|
|
281
|
+
`createIdempotencyMiddleware()` makes `POST`, `PUT`, and `PATCH` operations idempotent. When a client sends the same `X-Idempotency-Key` header twice, the second request receives the cached response without re-executing the handler.
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
import { createIdempotencyMiddleware } from 'sentri';
|
|
285
|
+
|
|
286
|
+
// Apply globally (before routes)
|
|
287
|
+
app.use(createIdempotencyMiddleware());
|
|
288
|
+
|
|
289
|
+
// Or scoped with custom options
|
|
290
|
+
apiRouter.use(createIdempotencyMiddleware({
|
|
291
|
+
ttl: 60_000, // cache TTL in ms — default: 300_000 (5 min)
|
|
292
|
+
header: 'X-Request-Id', // key header name — default: 'X-Idempotency-Key'
|
|
293
|
+
methods: ['POST'], // methods to watch — default: ['POST','PUT','PATCH']
|
|
294
|
+
}));
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### How it works
|
|
298
|
+
|
|
299
|
+
| Request | Key present | Cache hit | Behaviour |
|
|
300
|
+
|---|---|---|---|
|
|
301
|
+
| POST | No | — | Passes through unchanged |
|
|
302
|
+
| POST | Yes | No | Handler runs; response is cached (2xx only) |
|
|
303
|
+
| POST | Yes | Yes | Cached response returned immediately; `X-Idempotent-Replayed: true` header added |
|
|
304
|
+
|
|
305
|
+
The idempotency key is also attached as `req.requestId` and echoed in the `X-Request-Id` response header for all requests that include it.
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
app.post('/orders', createIdempotencyMiddleware(), async (req, res) => {
|
|
309
|
+
console.log(req.requestId); // the X-Idempotency-Key value
|
|
310
|
+
const order = await db.orders.create(req.body);
|
|
311
|
+
res.status(201).json({ error: false, data: order });
|
|
312
|
+
// On retry with same key → instant 201 from cache, no DB write
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Note:** The cache is in-memory and per-process. For multi-process deployments (clusters, containers) use a shared cache layer such as Redis.
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Lifecycle Hooks
|
|
321
|
+
|
|
322
|
+
`hooks` lets you fire side effects at key points in the authentication flow. Every hook is optional and runs fire-and-forget — a rejected hook Promise is silently swallowed so a broken hook can never abort or delay a login request.
|
|
146
323
|
|
|
147
324
|
```typescript
|
|
148
325
|
export const auth = createAuth({
|
|
149
326
|
// ...
|
|
150
|
-
|
|
327
|
+
hooks: {
|
|
328
|
+
/** Called after every successful login. */
|
|
329
|
+
onLogin: async (user) => {
|
|
330
|
+
await auditLog.record('login', { userId: user.id, identifier: user.identifier });
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/** Called after every failed login attempt. */
|
|
334
|
+
onFailedLogin: (identifier, error) => {
|
|
335
|
+
rateLimiter.hit(`login:${identifier}`);
|
|
336
|
+
logger.warn('Login failed', { identifier, code: error.code });
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
/** Called after logout (single session) and logout-all. */
|
|
340
|
+
onLogout: async (userId) => {
|
|
341
|
+
await cache.invalidate(userId);
|
|
342
|
+
},
|
|
343
|
+
},
|
|
151
344
|
});
|
|
152
345
|
```
|
|
153
346
|
|
|
154
|
-
|
|
347
|
+
### `AuthHooks` interface
|
|
348
|
+
|
|
349
|
+
| Hook | When | Receives |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| `onLogin` | After successful login | `AuthUser` |
|
|
352
|
+
| `onFailedLogin` | After failed login (wrong password or unknown identifier) | `identifier: string`, `error: SentriError` |
|
|
353
|
+
| `onLogout` | After `POST /logout-all` | `userId: string` |
|
|
155
354
|
|
|
355
|
+
> `POST /logout` (single session) does not fire `onLogout` — there is no authenticated user at that point. Use `onLogout` for "logout from all devices" events.
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Immediate Token Revocation (`isTokenRevoked`)
|
|
360
|
+
|
|
361
|
+
By default, sentri uses stateless access tokens — no DB lookup per request. If you need to revoke a token immediately (e.g. after a security incident), supply `isTokenRevoked`:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
export const auth = createAuth({
|
|
365
|
+
// ...
|
|
366
|
+
isTokenRevoked: async (sessionId) =>
|
|
367
|
+
await redis.sismember('revoked_sessions', sessionId),
|
|
368
|
+
});
|
|
156
369
|
```
|
|
157
|
-
|
|
370
|
+
|
|
371
|
+
After logout, add the `sessionId` to the revocation set:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// When handling a security incident — add to revoked set with a TTL
|
|
375
|
+
// equal to the access token lifetime so memory stays bounded.
|
|
376
|
+
await redis.sadd('revoked_sessions', sessionId);
|
|
377
|
+
await redis.expire('revoked_sessions', 300); // 5 min — matches accessExpiresIn
|
|
158
378
|
```
|
|
159
379
|
|
|
160
|
-
|
|
380
|
+
`isTokenRevoked` is called on **every** `protect()` invocation. Keep it fast (a single Redis `SISMEMBER`) or the latency benefit of stateless JWTs is lost. If a short revocation window equal to `accessExpiresIn` is acceptable, omit this hook entirely.
|
|
161
381
|
|
|
162
382
|
---
|
|
163
383
|
|
|
164
|
-
##
|
|
384
|
+
## apiKey — Restricting Registration
|
|
165
385
|
|
|
166
|
-
|
|
386
|
+
By default `POST /register` is open to the public. Set `apiKey` in your config to lock the endpoint:
|
|
167
387
|
|
|
168
|
-
|
|
388
|
+
```typescript
|
|
389
|
+
export const auth = createAuth({
|
|
390
|
+
// ...
|
|
391
|
+
apiKey: process.env.REGISTER_API_KEY!,
|
|
392
|
+
});
|
|
393
|
+
```
|
|
169
394
|
|
|
170
|
-
|
|
171
|
-
- `POST /logout-all` deletes **all** sessions for the user. Every access token across all devices is immediately rejected.
|
|
172
|
-
- Tokens issued before 1.1.0 (without the `sessionId` claim) are still accepted but bypass session validation — plan a rolling upgrade if you need strict enforcement for existing tokens.
|
|
395
|
+
Requests to `POST /register` must then include the header:
|
|
173
396
|
|
|
174
397
|
```
|
|
175
|
-
|
|
176
|
-
Request → protect() verifies JWT → checks session exists → ✓ allowed
|
|
177
|
-
Logout → session deleted
|
|
178
|
-
Request → protect() verifies JWT → session not found → ✗ 401 UNAUTHORIZED
|
|
398
|
+
X-Api-Key: <value of REGISTER_API_KEY>
|
|
179
399
|
```
|
|
180
400
|
|
|
181
|
-
|
|
401
|
+
Requests without the header, or with the wrong value, receive HTTP 401 `UNAUTHORIZED`. Keep the API key in an environment variable and share it only with trusted services.
|
|
182
402
|
|
|
183
403
|
---
|
|
184
404
|
|
|
@@ -186,10 +406,8 @@ Request → protect() verifies JWT → session not found → ✗ 401 UNAUTHORIZE
|
|
|
186
406
|
|
|
187
407
|
The `router` field in config lets you replace the built-in service logic for individual routes while the router still handles request parsing, input validation, and response formatting.
|
|
188
408
|
|
|
189
|
-
Each key is optional — only override what you need. Any key you omit falls back to the built-in behaviour.
|
|
190
|
-
|
|
191
409
|
```typescript
|
|
192
|
-
import { createAuth,
|
|
410
|
+
import { createAuth, SentriError } from 'sentri';
|
|
193
411
|
import type { AuthResult } from 'sentri';
|
|
194
412
|
|
|
195
413
|
export const auth = createAuth({
|
|
@@ -202,7 +420,7 @@ export const auth = createAuth({
|
|
|
202
420
|
login: async (input): Promise<AuthResult> => {
|
|
203
421
|
const otpVerified = await redis.get(`otp:${input.identifier}`);
|
|
204
422
|
if (!otpVerified) {
|
|
205
|
-
return { success: false, error: new
|
|
423
|
+
return { success: false, error: new SentriError('INVALID_CREDENTIALS', 'OTP required') };
|
|
206
424
|
}
|
|
207
425
|
return defaultLogin(input);
|
|
208
426
|
},
|
|
@@ -215,15 +433,6 @@ export const auth = createAuth({
|
|
|
215
433
|
}
|
|
216
434
|
return result;
|
|
217
435
|
},
|
|
218
|
-
|
|
219
|
-
// Audit-log every token rotation
|
|
220
|
-
refresh: async (refreshToken) => {
|
|
221
|
-
const result = await defaultRefresh(refreshToken);
|
|
222
|
-
if (result.success) {
|
|
223
|
-
await auditLog.record('token_rotated', result.user.id);
|
|
224
|
-
}
|
|
225
|
-
return result;
|
|
226
|
-
},
|
|
227
436
|
},
|
|
228
437
|
});
|
|
229
438
|
```
|
|
@@ -232,15 +441,13 @@ export const auth = createAuth({
|
|
|
232
441
|
|
|
233
442
|
| Key | Signature | Must return |
|
|
234
443
|
|---|---|---|
|
|
235
|
-
| `register` | `(input:
|
|
444
|
+
| `register` | `(input: RegisterInput) => Promise<RegisterResult>` | `RegisterResult` |
|
|
236
445
|
| `login` | `(input: LoginInput) => Promise<AuthResult>` | `AuthResult` |
|
|
237
446
|
| `refresh` | `(refreshToken: string) => Promise<RefreshResult>` | `RefreshResult` |
|
|
238
447
|
| `logout` | `(refreshToken: string \| undefined) => Promise<void>` | `void` |
|
|
239
448
|
| `logoutAll` | `(userId: string) => Promise<void>` | `void` |
|
|
240
449
|
| `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
|
|
241
450
|
|
|
242
|
-
The router always validates the request body and URL parameters before calling any handler. Your function receives the already-validated, trimmed input.
|
|
243
|
-
|
|
244
451
|
---
|
|
245
452
|
|
|
246
453
|
## Adapter Interface
|
|
@@ -268,27 +475,6 @@ const adapter: AuthAdapter = {
|
|
|
268
475
|
|
|
269
476
|
`identifier` is a single string — the adapter decides what it maps to (email column, username column, phone, or a combined lookup). sentri never assumes the column name.
|
|
270
477
|
|
|
271
|
-
### Using the generated adapter
|
|
272
|
-
|
|
273
|
-
The generated `adapter.ts` exports `createAdapter(db)` so you can pass your existing database instance:
|
|
274
|
-
|
|
275
|
-
```typescript
|
|
276
|
-
// Prisma
|
|
277
|
-
import { PrismaClient } from '@prisma/client';
|
|
278
|
-
import { createAdapter } from './adapter.js';
|
|
279
|
-
|
|
280
|
-
const prisma = new PrismaClient();
|
|
281
|
-
export const adapter = createAdapter(prisma);
|
|
282
|
-
|
|
283
|
-
// Drizzle
|
|
284
|
-
import { db } from '../db.js';
|
|
285
|
-
import { createAdapter } from './adapter.js';
|
|
286
|
-
|
|
287
|
-
export const adapter = createAdapter(db);
|
|
288
|
-
```
|
|
289
|
-
|
|
290
|
-
`createAdapter` throws `AuthError` with code `CONFIGURATION_ERROR` at runtime if called without a `db` argument.
|
|
291
|
-
|
|
292
478
|
---
|
|
293
479
|
|
|
294
480
|
## Pre-built Router
|
|
@@ -296,20 +482,13 @@ export const adapter = createAdapter(db);
|
|
|
296
482
|
`auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
|
|
297
483
|
|
|
298
484
|
```typescript
|
|
299
|
-
{
|
|
300
|
-
error: boolean,
|
|
301
|
-
statusCode: number,
|
|
302
|
-
message: string,
|
|
303
|
-
data: T | null
|
|
304
|
-
}
|
|
485
|
+
{ error: boolean, statusCode: number, message: string, data: T | null }
|
|
305
486
|
```
|
|
306
487
|
|
|
307
488
|
### Endpoints
|
|
308
489
|
|
|
309
490
|
#### `POST /register`
|
|
310
491
|
|
|
311
|
-
Register a new user. Does **not** issue tokens — call `/login` after registration.
|
|
312
|
-
|
|
313
492
|
```
|
|
314
493
|
Headers: X-Api-Key: <key> (required when config.apiKey is set)
|
|
315
494
|
Body: { identifier, password, roles?: string[] }
|
|
@@ -317,84 +496,56 @@ Returns: { user: { id, identifier, roles } }
|
|
|
317
496
|
Status: 201
|
|
318
497
|
```
|
|
319
498
|
|
|
320
|
-
`password` must be 8–72 characters. `roles` must be a subset of `validRoles`.
|
|
321
|
-
|
|
322
|
-
---
|
|
323
|
-
|
|
324
499
|
#### `POST /login`
|
|
325
500
|
|
|
326
|
-
Authenticate a user and start a session.
|
|
327
|
-
|
|
328
501
|
```
|
|
329
502
|
Body: { identifier, password }
|
|
330
503
|
Returns: { accessToken, user: { id, identifier, roles } }
|
|
504
|
+
Cookies: access_token=<jwt> (when config.accessCookie is set)
|
|
505
|
+
refresh_token=<jwt> (httpOnly)
|
|
331
506
|
Status: 200
|
|
332
507
|
```
|
|
333
508
|
|
|
334
|
-
The refresh token is stored in an httpOnly cookie. The access token is returned in the response body.
|
|
335
|
-
|
|
336
|
-
---
|
|
337
|
-
|
|
338
509
|
#### `POST /refresh`
|
|
339
510
|
|
|
340
|
-
Exchange the refresh token cookie for a new access token. Implements session rotation — the old session is deleted and a new one is created.
|
|
341
|
-
|
|
342
511
|
```
|
|
343
|
-
Cookie: refresh_token=<token>
|
|
512
|
+
Cookie: refresh_token=<token>
|
|
344
513
|
Returns: { accessToken }
|
|
514
|
+
Cookies: access_token=<new-jwt> (when config.accessCookie is set)
|
|
515
|
+
refresh_token=<new-jwt> (rotated)
|
|
345
516
|
Status: 200
|
|
346
517
|
```
|
|
347
518
|
|
|
348
|
-
The new refresh token is written back to the cookie. No body required.
|
|
349
|
-
|
|
350
|
-
---
|
|
351
|
-
|
|
352
519
|
#### `POST /logout`
|
|
353
520
|
|
|
354
|
-
Invalidate the current session.
|
|
355
|
-
|
|
356
521
|
```
|
|
357
522
|
Cookie: refresh_token=<token>
|
|
523
|
+
Clears: access_token, refresh_token cookies
|
|
358
524
|
Returns: null
|
|
359
525
|
Status: 200
|
|
360
526
|
```
|
|
361
527
|
|
|
362
|
-
After logout, any access token bound to this session is immediately rejected by `protect()`. Safe to call even if the cookie is missing or the token is already expired.
|
|
363
|
-
|
|
364
|
-
---
|
|
365
|
-
|
|
366
528
|
#### `POST /logout-all`
|
|
367
529
|
|
|
368
|
-
Invalidate all sessions for the authenticated user (logout from every device).
|
|
369
|
-
|
|
370
530
|
```
|
|
371
|
-
Headers: Authorization: Bearer <accessToken>
|
|
531
|
+
Headers: Authorization: Bearer <accessToken> (or access_token cookie)
|
|
532
|
+
Clears: access_token, refresh_token cookies
|
|
372
533
|
Returns: null
|
|
373
534
|
Status: 200
|
|
374
535
|
```
|
|
375
536
|
|
|
376
|
-
All access tokens across all devices are immediately rejected by `protect()` after this call.
|
|
377
|
-
|
|
378
|
-
---
|
|
379
|
-
|
|
380
537
|
#### `GET /me`
|
|
381
538
|
|
|
382
|
-
Return the currently authenticated user.
|
|
383
|
-
|
|
384
539
|
```
|
|
385
|
-
Headers: Authorization: Bearer <accessToken>
|
|
540
|
+
Headers: Authorization: Bearer <accessToken> (or access_token cookie)
|
|
386
541
|
Returns: { id, identifier, roles }
|
|
387
542
|
Status: 200
|
|
388
543
|
```
|
|
389
544
|
|
|
390
|
-
---
|
|
391
|
-
|
|
392
545
|
#### `POST /users/:userId/roles`
|
|
393
546
|
|
|
394
|
-
Add roles to another user. Restricted to users with the `admin` role. Merges the given roles with the user's existing roles — no duplicates.
|
|
395
|
-
|
|
396
547
|
```
|
|
397
|
-
Headers: Authorization: Bearer <accessToken>
|
|
548
|
+
Headers: Authorization: Bearer <accessToken> (must have admin role)
|
|
398
549
|
Body: { roles: string[] }
|
|
399
550
|
Returns: { user: { id, identifier, roles } }
|
|
400
551
|
Status: 200
|
|
@@ -406,7 +557,12 @@ Status: 200
|
|
|
406
557
|
|
|
407
558
|
### `auth.protect()`
|
|
408
559
|
|
|
409
|
-
Verifies the
|
|
560
|
+
Verifies the access token and injects `request.user`. Token is read from:
|
|
561
|
+
|
|
562
|
+
1. `Authorization: Bearer <token>` header
|
|
563
|
+
2. `access_token` cookie (when `config.accessCookie` is set)
|
|
564
|
+
|
|
565
|
+
When the token has expired, a silent refresh is attempted automatically. On success the request proceeds normally; on failure HTTP 401 is returned.
|
|
410
566
|
|
|
411
567
|
```typescript
|
|
412
568
|
router.get('/dashboard', auth.protect(), (request, response) => {
|
|
@@ -415,23 +571,18 @@ router.get('/dashboard', auth.protect(), (request, response) => {
|
|
|
415
571
|
```
|
|
416
572
|
|
|
417
573
|
Returns HTTP 401 if:
|
|
418
|
-
-
|
|
419
|
-
- The token signature is invalid
|
|
420
|
-
- The
|
|
574
|
+
- No token is present in header or cookie
|
|
575
|
+
- The token signature is invalid
|
|
576
|
+
- The token is expired **and** the refresh also fails
|
|
421
577
|
|
|
422
578
|
---
|
|
423
579
|
|
|
424
580
|
### `auth.authorize(...roles)`
|
|
425
581
|
|
|
426
|
-
Enforces role-based access. Must be used **after** `auth.protect()`.
|
|
582
|
+
Enforces role-based access. Must be used **after** `auth.protect()`.
|
|
427
583
|
|
|
428
584
|
```typescript
|
|
429
|
-
router.delete(
|
|
430
|
-
'/posts/:id',
|
|
431
|
-
auth.protect(),
|
|
432
|
-
auth.authorize('admin'),
|
|
433
|
-
handler,
|
|
434
|
-
);
|
|
585
|
+
router.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
|
|
435
586
|
```
|
|
436
587
|
|
|
437
588
|
---
|
|
@@ -441,23 +592,19 @@ router.delete(
|
|
|
441
592
|
Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
|
|
442
593
|
|
|
443
594
|
```typescript
|
|
444
|
-
//
|
|
445
|
-
router.put(
|
|
446
|
-
'
|
|
447
|
-
auth.protect(),
|
|
448
|
-
auth.permit((request) => request.user!.id === request.params['id']),
|
|
595
|
+
// Ownership check
|
|
596
|
+
router.put('/users/:id', auth.protect(),
|
|
597
|
+
auth.permit((req) => req.user!.id === req.params['id']),
|
|
449
598
|
handler,
|
|
450
599
|
);
|
|
451
600
|
|
|
452
|
-
//
|
|
453
|
-
router.delete(
|
|
454
|
-
'/posts/:id',
|
|
455
|
-
auth.protect(),
|
|
601
|
+
// Role bypass + custom check
|
|
602
|
+
router.delete('/posts/:id', auth.protect(),
|
|
456
603
|
auth.permit({
|
|
457
604
|
roles: ['admin'],
|
|
458
|
-
check: async (
|
|
459
|
-
const post = await db.post.
|
|
460
|
-
return post?.authorId ===
|
|
605
|
+
check: async (req) => {
|
|
606
|
+
const post = await db.post.findById(req.params['id']);
|
|
607
|
+
return post?.authorId === req.user!.id;
|
|
461
608
|
},
|
|
462
609
|
}),
|
|
463
610
|
handler,
|
|
@@ -466,23 +613,57 @@ router.delete(
|
|
|
466
613
|
|
|
467
614
|
---
|
|
468
615
|
|
|
616
|
+
### `createIdempotencyMiddleware(options?)`
|
|
617
|
+
|
|
618
|
+
Makes mutating operations idempotent via `X-Idempotency-Key` header. See [Request Idempotency](#request-idempotency).
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
469
622
|
## Programmatic API
|
|
470
623
|
|
|
471
|
-
Token
|
|
624
|
+
### Token utilities
|
|
472
625
|
|
|
473
626
|
```typescript
|
|
474
|
-
// Token utilities
|
|
475
627
|
const accessToken = auth.signAccessToken({ id, identifier, roles });
|
|
476
|
-
const user = auth.verifyAccessToken(accessToken); // throws
|
|
477
|
-
const { sessionId } = auth.verifyRefreshToken(token); // throws
|
|
628
|
+
const user = auth.verifyAccessToken(accessToken); // throws SentriError if invalid
|
|
629
|
+
const { sessionId } = auth.verifyRefreshToken(token); // throws SentriError if invalid
|
|
478
630
|
const refreshToken = auth.signRefreshToken(sessionId);
|
|
631
|
+
```
|
|
479
632
|
|
|
480
|
-
|
|
633
|
+
### Password utilities
|
|
634
|
+
|
|
635
|
+
```typescript
|
|
481
636
|
const hash = await auth.hashPassword('secret123');
|
|
482
637
|
const valid = await auth.verifyPassword('secret123', hash);
|
|
483
638
|
```
|
|
484
639
|
|
|
485
|
-
|
|
640
|
+
### Token extraction
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
// Read the raw access token from Authorization header or access_token cookie
|
|
644
|
+
const token = auth.getCurrentAccessToken(request);
|
|
645
|
+
|
|
646
|
+
// Or import and use directly with a config object
|
|
647
|
+
import { getCurrentAccessToken } from 'sentri';
|
|
648
|
+
const token = getCurrentAccessToken(request, config);
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### Standalone `register`
|
|
652
|
+
|
|
653
|
+
```typescript
|
|
654
|
+
import { register } from 'sentri';
|
|
655
|
+
|
|
656
|
+
const result = await register(
|
|
657
|
+
{ identifier: 'alice@example.com', password: 'hunter2', roles: ['user'] },
|
|
658
|
+
config,
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
if (result.success) {
|
|
662
|
+
console.log(result.user.id);
|
|
663
|
+
} else {
|
|
664
|
+
console.error(result.error.code); // 'USER_ALREADY_EXISTS' | 'INVALID_ROLE'
|
|
665
|
+
}
|
|
666
|
+
```
|
|
486
667
|
|
|
487
668
|
---
|
|
488
669
|
|
|
@@ -493,32 +674,42 @@ import type {
|
|
|
493
674
|
AuthConfig,
|
|
494
675
|
AuthClient,
|
|
495
676
|
AuthAdapter,
|
|
677
|
+
AuthHooks,
|
|
496
678
|
AuthUser,
|
|
497
679
|
AuthResult,
|
|
498
|
-
|
|
680
|
+
RegisterResult,
|
|
499
681
|
AssignRolesResult,
|
|
500
682
|
RefreshResult,
|
|
501
683
|
ApiResponse,
|
|
502
|
-
|
|
684
|
+
RegisterInput,
|
|
503
685
|
LoginInput,
|
|
504
686
|
RouterHandlers,
|
|
505
687
|
UserRecord,
|
|
506
688
|
SessionRecord,
|
|
507
689
|
CreateUserData,
|
|
508
690
|
CookieConfig,
|
|
691
|
+
AccessCookieConfig,
|
|
692
|
+
IdempotencyOptions,
|
|
509
693
|
PermitCheck,
|
|
510
694
|
PermitOptions,
|
|
511
|
-
|
|
695
|
+
SentriErrorCode,
|
|
512
696
|
} from 'sentri';
|
|
513
697
|
|
|
514
|
-
import {
|
|
698
|
+
import {
|
|
699
|
+
SentriError,
|
|
700
|
+
AUTH_ERROR_STATUS,
|
|
701
|
+
createAuth,
|
|
702
|
+
register,
|
|
703
|
+
createIdempotencyMiddleware,
|
|
704
|
+
getCurrentAccessToken,
|
|
705
|
+
} from 'sentri';
|
|
515
706
|
```
|
|
516
707
|
|
|
517
708
|
---
|
|
518
709
|
|
|
519
710
|
## Error Handling
|
|
520
711
|
|
|
521
|
-
All errors thrown by the library are instances of `
|
|
712
|
+
All errors thrown by the library are instances of `SentriError` with a machine-readable `code` and an HTTP `statusCode`:
|
|
522
713
|
|
|
523
714
|
| Code | HTTP | Meaning |
|
|
524
715
|
|---|---|---|
|
|
@@ -527,58 +718,75 @@ All errors thrown by the library are instances of `AuthError` with a machine-rea
|
|
|
527
718
|
| `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
|
|
528
719
|
| `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
|
|
529
720
|
| `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
|
|
530
|
-
| `UNAUTHORIZED` | 401 | No valid access token,
|
|
721
|
+
| `UNAUTHORIZED` | 401 | No valid access token, or session not found |
|
|
531
722
|
| `FORBIDDEN` | 403 | Authenticated but missing required role |
|
|
532
723
|
| `INVALID_ROLE` | 400 | Role name not in `validRoles` |
|
|
533
724
|
| `VALIDATION_ERROR` | 400 | Missing or invalid input field |
|
|
534
725
|
| `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` configuration |
|
|
535
726
|
|
|
536
|
-
|
|
727
|
+
### `auth.errorHandler()`
|
|
728
|
+
|
|
729
|
+
Mount **after all your routes**:
|
|
537
730
|
|
|
538
731
|
```typescript
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
INVALID_CREDENTIALS: 401,
|
|
546
|
-
FORBIDDEN: 403,
|
|
547
|
-
USER_NOT_FOUND: 404,
|
|
548
|
-
USER_ALREADY_EXISTS: 409,
|
|
549
|
-
INVALID_ROLE: 400,
|
|
550
|
-
VALIDATION_ERROR: 400,
|
|
551
|
-
CONFIGURATION_ERROR: 500,
|
|
552
|
-
};
|
|
732
|
+
app.use('/auth', auth.router());
|
|
733
|
+
app.use('/api', apiRouter);
|
|
734
|
+
app.use(auth.errorHandler());
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### Extending `SentriError`
|
|
553
738
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
code: error.code,
|
|
561
|
-
message: error.message,
|
|
562
|
-
data: null,
|
|
563
|
-
});
|
|
739
|
+
```typescript
|
|
740
|
+
import { SentriError } from 'sentri';
|
|
741
|
+
|
|
742
|
+
export class NotFoundError extends SentriError {
|
|
743
|
+
constructor(resource: string) {
|
|
744
|
+
super('NOT_FOUND', `${resource} not found`, 404);
|
|
564
745
|
}
|
|
565
|
-
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
app.get('/items/:id', auth.protect(), async (req, res) => {
|
|
749
|
+
const item = await db.items.findById(req.params['id']);
|
|
750
|
+
if (!item) throw new NotFoundError('Item');
|
|
751
|
+
res.json(item);
|
|
566
752
|
});
|
|
567
753
|
```
|
|
568
754
|
|
|
569
755
|
---
|
|
570
756
|
|
|
571
|
-
## Migration
|
|
757
|
+
## Migration Guide
|
|
572
758
|
|
|
573
|
-
### Breaking changes
|
|
759
|
+
### Breaking changes in 2.0.0
|
|
760
|
+
|
|
761
|
+
| What changed | Action required |
|
|
762
|
+
|---|---|
|
|
763
|
+
| `protect()` no longer performs a DB session check per request | Access tokens are now stateless. Logout does **not** immediately invalidate a live access token — it expires after `accessExpiresIn`. Keep this value short (`'5m'`). |
|
|
764
|
+
| `protect()` reads token from `access_token` cookie when `accessCookie` is configured | No action required if you use the `Authorization` header. Add `accessCookie` to config to enable cookie-based token delivery. |
|
|
765
|
+
| `protect()` silently refreshes expired tokens | Clients should read the `X-New-Access-Token` response header to detect and store the new token. |
|
|
766
|
+
|
|
767
|
+
### New in 2.0.0
|
|
768
|
+
|
|
769
|
+
- **`accessCookie`** — store the access token in a non-httpOnly cookie for SPA use.
|
|
770
|
+
- **`auth.getCurrentAccessToken(req)`** / **`getCurrentAccessToken(req, config)`** — extract the token from header or cookie.
|
|
771
|
+
- **`createIdempotencyMiddleware()`** — idempotent mutations via `X-Idempotency-Key`.
|
|
772
|
+
- **`hooks`** (`onLogin`, `onFailedLogin`, `onLogout`) — fire-and-forget lifecycle callbacks for audit logs, rate limiting, cache invalidation.
|
|
773
|
+
- **`isTokenRevoked`** — optional callback for Redis-backed immediate token revocation without giving up stateless JWTs.
|
|
774
|
+
- **Minified bundle** — library is built with tsup, reducing load time and install footprint.
|
|
775
|
+
- **Memoised config resolution** — `resolveConfig` is cached per config object; HMAC secret derivation is cached per secret string.
|
|
776
|
+
|
|
777
|
+
### Breaking changes in 1.1.0
|
|
574
778
|
|
|
575
779
|
| What changed | Action required |
|
|
576
780
|
|---|---|
|
|
577
781
|
| `POST /signup` renamed to `POST /register` | Update all client-side calls |
|
|
578
782
|
| `RouterHandlers.signup` renamed to `RouterHandlers.register` | Update config if you used a custom signup handler |
|
|
579
|
-
| `protect()` now performs one DB read per request | Ensure your adapter's `session.findById` is indexed on session ID |
|
|
580
783
|
|
|
581
|
-
###
|
|
784
|
+
### Breaking changes in 1.2.0
|
|
582
785
|
|
|
583
|
-
|
|
584
|
-
|
|
786
|
+
| What changed | Action required |
|
|
787
|
+
|---|---|
|
|
788
|
+
| `AuthError` renamed to `SentriError` | Replace all `import { AuthError }` with `import { SentriError }` |
|
|
789
|
+
| `AuthErrorCode` renamed to `SentriErrorCode` | Replace all `AuthErrorCode` type references |
|
|
790
|
+
| `SignupResult` renamed to `RegisterResult` | Replace type references |
|
|
791
|
+
| `SignupInput` renamed to `RegisterInput` | Replace type references |
|
|
792
|
+
| `signup` service renamed to `register` | Replace `import { signup }` with `import { register }` |
|