sentri 2.0.0 → 4.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 +372 -548
- package/dist/cli.js +112 -13
- package/dist/index.d.ts +303 -750
- 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,16 @@ 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)
|
|
17
|
+
- [Multi-Identifier](#multi-identifier)
|
|
12
18
|
- [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)
|
|
19
|
+
- [Endpoints](#endpoints)
|
|
23
20
|
- [Middleware](#middleware)
|
|
24
|
-
- [
|
|
25
|
-
- [Types](#types)
|
|
21
|
+
- [Token Utilities](#token-utilities)
|
|
26
22
|
- [Error Handling](#error-handling)
|
|
23
|
+
- [Request Idempotency](#request-idempotency)
|
|
27
24
|
- [Migration Guide](#migration-guide)
|
|
28
25
|
|
|
29
26
|
---
|
|
@@ -34,522 +31,399 @@ Auth and authorization library for Express + PostgreSQL. Provides stateless JWT
|
|
|
34
31
|
npm install sentri
|
|
35
32
|
```
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
---
|
|
40
|
-
|
|
41
|
-
## Quick Start
|
|
34
|
+
`kysely` and `pg` are bundled with sentri — no separate installation needed for PostgreSQL.
|
|
42
35
|
|
|
43
|
-
|
|
36
|
+
For other databases, install the driver:
|
|
44
37
|
|
|
45
38
|
```bash
|
|
46
|
-
#
|
|
47
|
-
|
|
39
|
+
# MySQL
|
|
40
|
+
npm install mysql2
|
|
48
41
|
|
|
49
|
-
#
|
|
50
|
-
|
|
42
|
+
# SQLite
|
|
43
|
+
npm install better-sqlite3
|
|
51
44
|
```
|
|
52
45
|
|
|
53
|
-
|
|
46
|
+
**Peer dependency:** `express >= 4.0.0`
|
|
54
47
|
|
|
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
|
-
```
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
65
51
|
|
|
66
|
-
###
|
|
52
|
+
### Server Mode — PostgreSQL (recommended)
|
|
67
53
|
|
|
68
54
|
```typescript
|
|
69
55
|
import express from 'express';
|
|
70
|
-
import {
|
|
71
|
-
|
|
56
|
+
import { createAuthServer } from 'sentri';
|
|
57
|
+
|
|
58
|
+
const auth = createAuthServer({
|
|
59
|
+
validRoles: ['user', 'admin'] as const,
|
|
60
|
+
db: { connectionString: process.env.DATABASE_URL! },
|
|
61
|
+
});
|
|
72
62
|
|
|
73
63
|
const app = express();
|
|
74
64
|
app.use(express.json());
|
|
65
|
+
app.use(auth.idempotencyMiddleware());
|
|
75
66
|
|
|
76
|
-
|
|
77
|
-
app.use(createIdempotencyMiddleware());
|
|
78
|
-
|
|
67
|
+
await auth.migrate();
|
|
79
68
|
app.use('/auth', auth.router());
|
|
80
69
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Must be last — catches SentriError from sentri and your own subclasses
|
|
70
|
+
app.get('/me', auth.protect(), (req, res) => res.json(req.user));
|
|
84
71
|
app.use(auth.errorHandler());
|
|
85
72
|
app.listen(3000);
|
|
86
73
|
```
|
|
87
74
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
---
|
|
91
|
-
|
|
92
|
-
## CLI
|
|
75
|
+
`createAuthServer()` generates an RSA-2048 key pair at startup and enables `GET /keys` automatically.
|
|
93
76
|
|
|
94
|
-
###
|
|
77
|
+
### Server Mode — Custom Dialect
|
|
95
78
|
|
|
96
|
-
|
|
79
|
+
```typescript
|
|
80
|
+
import express from 'express';
|
|
81
|
+
import { createAuth } from 'sentri';
|
|
82
|
+
import { PostgresDialect } from 'kysely'; // kysely is bundled, no install needed
|
|
83
|
+
import { Pool } from 'pg'; // pg is bundled, no install needed
|
|
84
|
+
|
|
85
|
+
const auth = createAuth({
|
|
86
|
+
mode: 'server',
|
|
87
|
+
dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL }) }),
|
|
88
|
+
secret: process.env.JWT_PRIVATE_KEY!, // RSA private key PEM (RS256) or plain string (HS256)
|
|
89
|
+
algorithm: 'RS256',
|
|
90
|
+
validRoles: ['user', 'admin'] as const,
|
|
91
|
+
});
|
|
97
92
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
const app = express();
|
|
94
|
+
app.use(express.json());
|
|
95
|
+
await auth.migrate();
|
|
96
|
+
app.use('/auth', auth.router());
|
|
97
|
+
app.use(auth.errorHandler());
|
|
98
|
+
app.listen(3000);
|
|
101
99
|
```
|
|
102
100
|
|
|
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
|
|
101
|
+
### Client Mode (Other Apps)
|
|
116
102
|
|
|
117
103
|
```typescript
|
|
104
|
+
import express from 'express';
|
|
118
105
|
import { createAuth } from 'sentri';
|
|
119
106
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// optional
|
|
126
|
-
accessExpiresIn: '5m', // default: '15m' — short is safe: auto-refresh handles it
|
|
127
|
-
refreshExpiresIn: '7d', // default: '7d'
|
|
128
|
-
algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
|
|
129
|
-
saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
|
|
130
|
-
|
|
131
|
-
// API key for POST /register — see apiKey section
|
|
132
|
-
apiKey: process.env.REGISTER_API_KEY, // optional
|
|
133
|
-
|
|
134
|
-
// Access token cookie — non-httpOnly so browser JS can read it.
|
|
135
|
-
// When set, protect() reads from this cookie when no Authorization header is present.
|
|
136
|
-
accessCookie: {
|
|
137
|
-
secure: process.env.NODE_ENV === 'production',
|
|
138
|
-
// name: 'access_token', // default: 'access_token'
|
|
139
|
-
// sameSite: 'strict', // default: 'strict'
|
|
140
|
-
// path: '/', // default: '/'
|
|
141
|
-
},
|
|
107
|
+
const auth = createAuth({
|
|
108
|
+
mode: 'client',
|
|
109
|
+
keyUri: 'https://auth.myapp.com/auth/keys',
|
|
110
|
+
});
|
|
142
111
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
// name: 'refresh_token', // default: 'refresh_token'
|
|
147
|
-
// httpOnly: true, // default: true
|
|
148
|
-
// sameSite: 'strict', // default: 'strict'
|
|
149
|
-
// path: '/', // default: '/'
|
|
150
|
-
},
|
|
112
|
+
const app = express();
|
|
113
|
+
app.get('/products', auth.protect(), auth.authorize('admin'), handler);
|
|
114
|
+
app.get('/orders', auth.protect(), auth.permit(req => req.user!.id === req.params.userId), handler);
|
|
151
115
|
|
|
152
|
-
|
|
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
|
-
});
|
|
116
|
+
app.use(auth.errorHandler());
|
|
172
117
|
```
|
|
173
118
|
|
|
174
|
-
`accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'5m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
|
|
175
|
-
|
|
176
119
|
---
|
|
177
120
|
|
|
178
|
-
##
|
|
121
|
+
## Server Mode
|
|
179
122
|
|
|
180
|
-
|
|
123
|
+
Server mode manages users, sessions, and tokens entirely within Sentri. The user provides a database dialect; Sentri handles the schema.
|
|
181
124
|
|
|
182
|
-
|
|
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:
|
|
125
|
+
### `createAuthServer(options)` — PostgreSQL shortcut
|
|
190
126
|
|
|
191
127
|
```typescript
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// ...
|
|
197
|
-
});
|
|
198
|
-
```
|
|
128
|
+
import { createAuthServer } from 'sentri';
|
|
129
|
+
|
|
130
|
+
const auth = createAuthServer({
|
|
131
|
+
validRoles: ['user', 'admin'] as const,
|
|
199
132
|
|
|
200
|
-
|
|
133
|
+
// Connection string
|
|
134
|
+
db: { connectionString: process.env.DATABASE_URL!, max: 10 },
|
|
201
135
|
|
|
202
|
-
|
|
136
|
+
// — or individual params —
|
|
137
|
+
// db: { host: 'localhost', port: 5432, database: 'mydb', user: 'app', password: 'secret' },
|
|
203
138
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
139
|
+
// Optional
|
|
140
|
+
accessExpiresIn: '15m',
|
|
141
|
+
refreshExpiresIn: '7d',
|
|
142
|
+
saltRounds: 12,
|
|
143
|
+
apiKey: process.env.REGISTER_API_KEY,
|
|
144
|
+
redisUrl: process.env.REDIS_URL, // enables Redis-backed idempotency cache
|
|
145
|
+
});
|
|
210
146
|
```
|
|
211
147
|
|
|
212
|
-
###
|
|
148
|
+
### `createAuth(config)` — Full config
|
|
213
149
|
|
|
214
150
|
```typescript
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
151
|
+
createAuth({
|
|
152
|
+
mode: 'server',
|
|
153
|
+
dialect, // required — Kysely Dialect
|
|
154
|
+
secret: process.env.JWT_SECRET!, // required — RSA private key PEM (RS256) or string (HS256)
|
|
155
|
+
validRoles: ['user', 'admin'] as const, // required
|
|
156
|
+
|
|
157
|
+
algorithm: 'RS256', // default: 'HS256' | also: 'HS384','HS512','RS384','RS512'
|
|
158
|
+
accessExpiresIn: '15m', // default: '15m'
|
|
159
|
+
refreshExpiresIn: '7d', // default: '7d'
|
|
160
|
+
saltRounds: 12, // default: 12 (bcrypt rounds, 10–31)
|
|
161
|
+
apiKey: process.env.REGISTER_API_KEY, // restricts POST /register
|
|
162
|
+
redisUrl: process.env.REDIS_URL, // Redis URL for idempotency cache
|
|
163
|
+
cookie: { secure: true }, // httpOnly refresh token cookie
|
|
164
|
+
accessCookie: { secure: true }, // non-httpOnly access token cookie (SPA)
|
|
165
|
+
hooks: { onLogin, onFailedLogin, onLogout },
|
|
166
|
+
isTokenRevoked: async (sessionId) => await redis.sismember('revoked', sessionId),
|
|
167
|
+
router: { // override built-in service functions
|
|
168
|
+
login, register, refresh, logout, logoutAll, assignRoles,
|
|
169
|
+
bulkCreateIdentifiers, bulkUpdateIdentifiers, bulkDeleteIdentifiers,
|
|
170
|
+
changePrimaryIdentifier, changePassword,
|
|
171
|
+
},
|
|
221
172
|
});
|
|
222
|
-
|
|
223
|
-
// Or via the auth client (pre-bound to config):
|
|
224
|
-
const token = auth.getCurrentAccessToken(req);
|
|
225
173
|
```
|
|
226
174
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
## Stateless Access Tokens
|
|
175
|
+
### Database Migration
|
|
230
176
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
Request → protect() verifies JWT signature + expiry → ✓ allowed
|
|
235
|
-
(no DB round-trip per request)
|
|
177
|
+
```typescript
|
|
178
|
+
// Call once at startup — uses IF NOT EXISTS, safe to repeat
|
|
179
|
+
await auth.migrate();
|
|
236
180
|
```
|
|
237
181
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
Request with old access token → accepted until exp (≤ 5m)
|
|
243
|
-
Request with old refresh token → rejected immediately (session gone)
|
|
244
|
-
```
|
|
182
|
+
Creates three tables:
|
|
183
|
+
- `sentri_users` — id, password_hash, roles (JSON), created_at
|
|
184
|
+
- `sentri_sessions` — id, user_id, expires_at, created_at
|
|
185
|
+
- `sentri_identifiers` — id, user_id, type, value (globally unique), is_primary, created_at
|
|
245
186
|
|
|
246
187
|
---
|
|
247
188
|
|
|
248
|
-
##
|
|
249
|
-
|
|
250
|
-
When `protect()` encounters an expired access token, it performs one silent refresh before returning an error:
|
|
189
|
+
## Client Mode
|
|
251
190
|
|
|
252
|
-
|
|
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.
|
|
191
|
+
Client mode has no database. It fetches the auth server's public key and validates JWTs stateless.
|
|
258
192
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
→ refresh fails → 401 UNAUTHORIZED — user must login
|
|
193
|
+
```typescript
|
|
194
|
+
createAuth({
|
|
195
|
+
mode: 'client',
|
|
196
|
+
keyUri: 'https://auth.myapp.com/auth/keys', // required
|
|
197
|
+
validRoles: ['admin', 'user'], // optional — TypeScript type safety only
|
|
198
|
+
});
|
|
266
199
|
```
|
|
267
200
|
|
|
268
|
-
|
|
201
|
+
Available methods: `protect()`, `authorize()`, `permit()`, `errorHandler()`.
|
|
269
202
|
|
|
270
|
-
|
|
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.
|
|
203
|
+
The public key is fetched once and cached for 1 hour. Token validation is fully stateless.
|
|
276
204
|
|
|
277
205
|
---
|
|
278
206
|
|
|
279
|
-
##
|
|
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';
|
|
207
|
+
## SSO Flow
|
|
285
208
|
|
|
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
209
|
```
|
|
210
|
+
[User] → POST /auth/login (Sentri Auth Server)
|
|
211
|
+
← { accessToken, user }
|
|
296
212
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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 |
|
|
213
|
+
[User] → GET /products (App A — client mode)
|
|
214
|
+
Authorization: Bearer <accessToken>
|
|
215
|
+
← App A fetches public key from GET /auth/keys, verifies JWT
|
|
216
|
+
← 200 OK
|
|
217
|
+
```
|
|
304
218
|
|
|
305
|
-
|
|
219
|
+
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).
|
|
306
220
|
|
|
307
|
-
```
|
|
308
|
-
|
|
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
|
-
});
|
|
221
|
+
```
|
|
222
|
+
GET /auth/keys → { keys: [{ kty, use, kid, n, e, ... }] }
|
|
314
223
|
```
|
|
315
224
|
|
|
316
|
-
|
|
225
|
+
Client apps point `keyUri` at this endpoint and receive the public key automatically.
|
|
317
226
|
|
|
318
227
|
---
|
|
319
228
|
|
|
320
|
-
##
|
|
229
|
+
## Multi-Identifier
|
|
321
230
|
|
|
322
|
-
|
|
231
|
+
Each user can have multiple identifiers — email, username, phone number, or any custom type. All identifier values are **globally unique** regardless of type.
|
|
323
232
|
|
|
324
|
-
|
|
325
|
-
export const auth = createAuth({
|
|
326
|
-
// ...
|
|
327
|
-
hooks: {
|
|
328
|
-
/** Called after every successful login. */
|
|
329
|
-
onLogin: async (user) => {
|
|
330
|
-
await auditLog.record('login', { userId: user.id, identifier: user.identifier });
|
|
331
|
-
},
|
|
233
|
+
One identifier per user is marked as **primary** and its value is embedded in the JWT payload. Regardless of which identifier a user logs in with, the JWT always contains the primary identifier.
|
|
332
234
|
|
|
333
|
-
|
|
334
|
-
onFailedLogin: (identifier, error) => {
|
|
335
|
-
rateLimiter.hit(`login:${identifier}`);
|
|
336
|
-
logger.warn('Login failed', { identifier, code: error.code });
|
|
337
|
-
},
|
|
235
|
+
### Registration
|
|
338
236
|
|
|
339
|
-
|
|
340
|
-
onLogout: async (userId) => {
|
|
341
|
-
await cache.invalidate(userId);
|
|
342
|
-
},
|
|
343
|
-
},
|
|
344
|
-
});
|
|
345
|
-
```
|
|
237
|
+
Provide at least one identifier. The first entry becomes the primary.
|
|
346
238
|
|
|
347
|
-
|
|
239
|
+
```
|
|
240
|
+
POST /register
|
|
241
|
+
Content-Type: application/json
|
|
348
242
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
243
|
+
{
|
|
244
|
+
"identifiers": [
|
|
245
|
+
{ "type": "email", "value": "rizz@example.com" },
|
|
246
|
+
{ "type": "username", "value": "rizz" }
|
|
247
|
+
],
|
|
248
|
+
"password": "secret123",
|
|
249
|
+
"roles": ["user"]
|
|
250
|
+
}
|
|
251
|
+
```
|
|
354
252
|
|
|
355
|
-
|
|
253
|
+
**Response:**
|
|
254
|
+
```json
|
|
255
|
+
{
|
|
256
|
+
"error": false,
|
|
257
|
+
"statusCode": 201,
|
|
258
|
+
"message": "User registered successfully",
|
|
259
|
+
"data": {
|
|
260
|
+
"user": {
|
|
261
|
+
"id": "uuid",
|
|
262
|
+
"identifier": "rizz@example.com",
|
|
263
|
+
"identifierType": "email",
|
|
264
|
+
"roles": ["user"],
|
|
265
|
+
"identifiers": [
|
|
266
|
+
{ "id": "uuid-1", "type": "email", "value": "rizz@example.com", "isPrimary": true },
|
|
267
|
+
{ "id": "uuid-2", "type": "username", "value": "rizz", "isPrimary": false }
|
|
268
|
+
]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
356
273
|
|
|
357
|
-
|
|
274
|
+
### Login
|
|
358
275
|
|
|
359
|
-
|
|
276
|
+
Send any of the user's identifier values — Sentri searches all types automatically.
|
|
360
277
|
|
|
361
|
-
|
|
278
|
+
```
|
|
279
|
+
POST /login
|
|
280
|
+
Content-Type: application/json
|
|
362
281
|
|
|
363
|
-
|
|
364
|
-
export const auth = createAuth({
|
|
365
|
-
// ...
|
|
366
|
-
isTokenRevoked: async (sessionId) =>
|
|
367
|
-
await redis.sismember('revoked_sessions', sessionId),
|
|
368
|
-
});
|
|
282
|
+
{ "identifier": "rizz", "password": "secret123" }
|
|
369
283
|
```
|
|
370
284
|
|
|
371
|
-
|
|
285
|
+
### Bulk Create Identifiers
|
|
372
286
|
|
|
373
|
-
```typescript
|
|
374
|
-
// When handling a security incident — add to revoked set with a TTL
|
|
375
|
-
// equal to the access token lifetime so memory stays bounded.
|
|
376
|
-
await redis.sadd('revoked_sessions', sessionId);
|
|
377
|
-
await redis.expire('revoked_sessions', 300); // 5 min — matches accessExpiresIn
|
|
378
287
|
```
|
|
288
|
+
POST /me/identifiers
|
|
289
|
+
Authorization: Bearer <token>
|
|
290
|
+
Content-Type: application/json
|
|
379
291
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
292
|
+
{
|
|
293
|
+
"identifiers": [
|
|
294
|
+
{ "type": "phone", "value": "+628123456789" }
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
```
|
|
383
298
|
|
|
384
|
-
|
|
299
|
+
### Bulk Update Identifiers
|
|
385
300
|
|
|
386
|
-
|
|
301
|
+
```
|
|
302
|
+
PUT /me/identifiers
|
|
303
|
+
Authorization: Bearer <token>
|
|
304
|
+
Content-Type: application/json
|
|
387
305
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
306
|
+
{
|
|
307
|
+
"identifiers": [
|
|
308
|
+
{ "id": "uuid-2", "type": "username", "value": "newrizz" }
|
|
309
|
+
]
|
|
310
|
+
}
|
|
393
311
|
```
|
|
394
312
|
|
|
395
|
-
|
|
313
|
+
### Bulk Delete Identifiers
|
|
396
314
|
|
|
397
315
|
```
|
|
398
|
-
|
|
316
|
+
DELETE /me/identifiers
|
|
317
|
+
Authorization: Bearer <token>
|
|
318
|
+
Content-Type: application/json
|
|
319
|
+
|
|
320
|
+
{ "ids": ["uuid-2"] }
|
|
399
321
|
```
|
|
400
322
|
|
|
401
|
-
|
|
323
|
+
At least one identifier must remain after deletion.
|
|
402
324
|
|
|
403
|
-
|
|
325
|
+
### Change Primary Identifier
|
|
404
326
|
|
|
405
|
-
|
|
327
|
+
```
|
|
328
|
+
PATCH /me/identifiers/primary
|
|
329
|
+
Authorization: Bearer <token>
|
|
330
|
+
Content-Type: application/json
|
|
406
331
|
|
|
407
|
-
|
|
332
|
+
{ "id": "uuid-2" }
|
|
333
|
+
```
|
|
408
334
|
|
|
409
|
-
|
|
410
|
-
import { createAuth, SentriError } from 'sentri';
|
|
411
|
-
import type { AuthResult } from 'sentri';
|
|
335
|
+
The new primary value will be embedded in the JWT on the next login or token refresh.
|
|
412
336
|
|
|
413
|
-
|
|
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
|
-
},
|
|
337
|
+
### Programmatic API
|
|
427
338
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
},
|
|
436
|
-
|
|
339
|
+
```typescript
|
|
340
|
+
const auth = createAuth({ mode: 'server', ... });
|
|
341
|
+
|
|
342
|
+
// Register with multiple identifiers
|
|
343
|
+
await auth.register({
|
|
344
|
+
identifiers: [
|
|
345
|
+
{ type: 'email', value: 'rizz@example.com' },
|
|
346
|
+
{ type: 'username', value: 'rizz' },
|
|
347
|
+
],
|
|
348
|
+
password: 'secret123',
|
|
437
349
|
});
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
### Available handler signatures
|
|
441
350
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
| `register` | `(input: RegisterInput) => Promise<RegisterResult>` | `RegisterResult` |
|
|
445
|
-
| `login` | `(input: LoginInput) => Promise<AuthResult>` | `AuthResult` |
|
|
446
|
-
| `refresh` | `(refreshToken: string) => Promise<RefreshResult>` | `RefreshResult` |
|
|
447
|
-
| `logout` | `(refreshToken: string \| undefined) => Promise<void>` | `void` |
|
|
448
|
-
| `logoutAll` | `(userId: string) => Promise<void>` | `void` |
|
|
449
|
-
| `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
|
|
351
|
+
// Add identifiers after registration
|
|
352
|
+
await auth.bulkCreateIdentifiers(userId, [{ type: 'phone', value: '+628123456789' }]);
|
|
450
353
|
|
|
451
|
-
|
|
354
|
+
// Update identifiers
|
|
355
|
+
await auth.bulkUpdateIdentifiers(userId, [{ id: 'uuid-2', type: 'username', value: 'newrizz' }]);
|
|
452
356
|
|
|
453
|
-
|
|
357
|
+
// Delete identifiers
|
|
358
|
+
await auth.bulkDeleteIdentifiers(userId, ['uuid-2']);
|
|
454
359
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
```typescript
|
|
458
|
-
import type { AuthAdapter } from 'sentri';
|
|
459
|
-
|
|
460
|
-
const adapter: AuthAdapter = {
|
|
461
|
-
user: {
|
|
462
|
-
findByIdentifier(identifier: string): Promise<UserRecord | null>,
|
|
463
|
-
findById(id: string): Promise<UserRecord | null>,
|
|
464
|
-
create(data: CreateUserData): Promise<{ id: string }>,
|
|
465
|
-
updateRoles(userId: string, roles: string[]): Promise<void>,
|
|
466
|
-
},
|
|
467
|
-
session: {
|
|
468
|
-
create(data: { userId: string; expiresAt: Date }): Promise<{ id: string }>,
|
|
469
|
-
findById(sessionId: string): Promise<(SessionRecord & { user: UserRecord }) | null>,
|
|
470
|
-
delete(sessionId: string): Promise<void>,
|
|
471
|
-
deleteAllForUser(userId: string): Promise<void>,
|
|
472
|
-
},
|
|
473
|
-
};
|
|
360
|
+
// Change primary
|
|
361
|
+
await auth.changePrimaryIdentifier(userId, 'uuid-3');
|
|
474
362
|
```
|
|
475
363
|
|
|
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.
|
|
477
|
-
|
|
478
364
|
---
|
|
479
365
|
|
|
480
|
-
##
|
|
366
|
+
## Configuration
|
|
481
367
|
|
|
482
|
-
`
|
|
368
|
+
### `algorithm`
|
|
483
369
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
370
|
+
| Value | Type | Use case |
|
|
371
|
+
|---|---|---|
|
|
372
|
+
| `'HS256'` | Symmetric (default) | Single app, shared secret |
|
|
373
|
+
| `'RS256'` | Asymmetric | SSO — enables `GET /keys` |
|
|
487
374
|
|
|
488
|
-
|
|
375
|
+
When using RS256, `secret` must be a valid RSA private key in PEM format. `createAuthServer()` generates the key pair automatically.
|
|
489
376
|
|
|
490
|
-
|
|
377
|
+
### Cookie Strategy (SPA)
|
|
491
378
|
|
|
492
|
-
```
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
379
|
+
```typescript
|
|
380
|
+
createAuth({
|
|
381
|
+
mode: 'server',
|
|
382
|
+
cookie: { secure: true }, // httpOnly refresh token
|
|
383
|
+
accessCookie: { secure: true }, // non-httpOnly access token (readable by JS)
|
|
384
|
+
});
|
|
497
385
|
```
|
|
498
386
|
|
|
499
|
-
|
|
387
|
+
After login, both cookies are set automatically. `protect()` reads the access token from the cookie when no `Authorization` header is present.
|
|
500
388
|
|
|
501
|
-
|
|
502
|
-
Body: { identifier, password }
|
|
503
|
-
Returns: { accessToken, user: { id, identifier, roles } }
|
|
504
|
-
Cookies: access_token=<jwt> (when config.accessCookie is set)
|
|
505
|
-
refresh_token=<jwt> (httpOnly)
|
|
506
|
-
Status: 200
|
|
507
|
-
```
|
|
389
|
+
---
|
|
508
390
|
|
|
509
|
-
|
|
391
|
+
## Endpoints
|
|
510
392
|
|
|
511
|
-
|
|
512
|
-
Cookie: refresh_token=<token>
|
|
513
|
-
Returns: { accessToken }
|
|
514
|
-
Cookies: access_token=<new-jwt> (when config.accessCookie is set)
|
|
515
|
-
refresh_token=<new-jwt> (rotated)
|
|
516
|
-
Status: 200
|
|
517
|
-
```
|
|
393
|
+
`auth.router()` mounts these endpoints:
|
|
518
394
|
|
|
519
|
-
|
|
395
|
+
| Method | Path | Auth | Description |
|
|
396
|
+
|---|---|---|---|
|
|
397
|
+
| `POST` | `/register` | — | Create a user (requires `X-Api-Key` when `apiKey` is set) |
|
|
398
|
+
| `POST` | `/login` | — | Authenticate, receive tokens |
|
|
399
|
+
| `POST` | `/refresh` | — | Rotate refresh token |
|
|
400
|
+
| `POST` | `/logout` | — | Invalidate current session |
|
|
401
|
+
| `POST` | `/logout-all` | ✓ | Invalidate all sessions |
|
|
402
|
+
| `GET` | `/me` | ✓ | Return authenticated user with all identifiers |
|
|
403
|
+
| `POST` | `/me/identifiers` | ✓ self | Add identifiers in bulk |
|
|
404
|
+
| `PUT` | `/me/identifiers` | ✓ self | Update identifiers in bulk |
|
|
405
|
+
| `DELETE` | `/me/identifiers` | ✓ self | Delete identifiers in bulk |
|
|
406
|
+
| `PATCH` | `/me/identifiers/primary` | ✓ self | Change primary identifier |
|
|
407
|
+
| `PATCH` | `/me/password` | ✓ self | Change password — revokes all sessions |
|
|
408
|
+
| `POST` | `/users/:userId/roles` | ✓ admin | Assign roles to user |
|
|
409
|
+
| `GET` | `/keys` | — | Public key in JWKS format (RS256 only) |
|
|
520
410
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
Returns: null
|
|
525
|
-
Status: 200
|
|
411
|
+
All responses use the envelope:
|
|
412
|
+
```json
|
|
413
|
+
{ "error": false, "statusCode": 200, "message": "...", "data": { ... } }
|
|
526
414
|
```
|
|
527
415
|
|
|
528
|
-
|
|
416
|
+
### Change Password
|
|
529
417
|
|
|
530
418
|
```
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
Status: 200
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
#### `GET /me`
|
|
419
|
+
PATCH /me/password
|
|
420
|
+
Authorization: Bearer <token>
|
|
421
|
+
Content-Type: application/json
|
|
538
422
|
|
|
539
|
-
|
|
540
|
-
Headers: Authorization: Bearer <accessToken> (or access_token cookie)
|
|
541
|
-
Returns: { id, identifier, roles }
|
|
542
|
-
Status: 200
|
|
423
|
+
{ "currentPassword": "old-pass", "newPassword": "new-pass" }
|
|
543
424
|
```
|
|
544
425
|
|
|
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
|
-
```
|
|
426
|
+
Changing the password revokes all existing sessions. The user must log in again after this call.
|
|
553
427
|
|
|
554
428
|
---
|
|
555
429
|
|
|
@@ -557,53 +431,40 @@ Status: 200
|
|
|
557
431
|
|
|
558
432
|
### `auth.protect()`
|
|
559
433
|
|
|
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.
|
|
434
|
+
Verifies the JWT and sets `req.user`. In server mode, also performs silent token refresh when the access token expires.
|
|
566
435
|
|
|
567
436
|
```typescript
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
});
|
|
437
|
+
app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
|
|
438
|
+
// req.user: { id, identifier, identifierType, roles, identifiers? }
|
|
571
439
|
```
|
|
572
440
|
|
|
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
|
-
---
|
|
441
|
+
Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
|
|
579
442
|
|
|
580
443
|
### `auth.authorize(...roles)`
|
|
581
444
|
|
|
582
|
-
|
|
445
|
+
Role-based access — must follow `protect()`.
|
|
583
446
|
|
|
584
447
|
```typescript
|
|
585
|
-
|
|
448
|
+
app.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
|
|
586
449
|
```
|
|
587
450
|
|
|
588
|
-
---
|
|
589
|
-
|
|
590
451
|
### `auth.permit(check | options)`
|
|
591
452
|
|
|
592
|
-
Resource-level permission
|
|
453
|
+
Resource-level permission — must follow `protect()`.
|
|
593
454
|
|
|
594
455
|
```typescript
|
|
595
456
|
// Ownership check
|
|
596
|
-
|
|
597
|
-
auth.permit((req) => req.user!.id === req.params
|
|
457
|
+
app.put('/users/:id', auth.protect(),
|
|
458
|
+
auth.permit((req) => req.user!.id === req.params.id),
|
|
598
459
|
handler,
|
|
599
460
|
);
|
|
600
461
|
|
|
601
|
-
// Role bypass +
|
|
602
|
-
|
|
462
|
+
// Role bypass + ownership check
|
|
463
|
+
app.delete('/posts/:id', auth.protect(),
|
|
603
464
|
auth.permit({
|
|
604
465
|
roles: ['admin'],
|
|
605
466
|
check: async (req) => {
|
|
606
|
-
const post = await db.
|
|
467
|
+
const post = await db.posts.findById(req.params.id);
|
|
607
468
|
return post?.authorId === req.user!.id;
|
|
608
469
|
},
|
|
609
470
|
}),
|
|
@@ -613,140 +474,68 @@ router.delete('/posts/:id', auth.protect(),
|
|
|
613
474
|
|
|
614
475
|
---
|
|
615
476
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
Makes mutating operations idempotent via `X-Idempotency-Key` header. See [Request Idempotency](#request-idempotency).
|
|
619
|
-
|
|
620
|
-
---
|
|
621
|
-
|
|
622
|
-
## Programmatic API
|
|
477
|
+
## Token Utilities
|
|
623
478
|
|
|
624
|
-
|
|
479
|
+
Available on `ServerAuthClient` only:
|
|
625
480
|
|
|
626
481
|
```typescript
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
482
|
+
const auth = createAuth({ mode: 'server', ... });
|
|
483
|
+
|
|
484
|
+
// Sign
|
|
485
|
+
const accessToken = auth.signAccessToken({ id, identifier, identifierType, roles });
|
|
630
486
|
const refreshToken = auth.signRefreshToken(sessionId);
|
|
631
|
-
```
|
|
632
487
|
|
|
633
|
-
|
|
488
|
+
// Verify
|
|
489
|
+
const user = auth.verifyAccessToken(accessToken);
|
|
490
|
+
const { sessionId } = auth.verifyRefreshToken(refreshToken);
|
|
634
491
|
|
|
635
|
-
|
|
636
|
-
const hash
|
|
492
|
+
// Password
|
|
493
|
+
const hash = await auth.hashPassword('secret123');
|
|
637
494
|
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
|
-
|
|
651
|
-
### Standalone `register`
|
|
652
|
-
|
|
653
|
-
```typescript
|
|
654
|
-
import { register } from 'sentri';
|
|
655
495
|
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
}
|
|
496
|
+
// Extract raw token from request
|
|
497
|
+
const token = auth.getCurrentAccessToken(req);
|
|
666
498
|
```
|
|
667
499
|
|
|
668
500
|
---
|
|
669
501
|
|
|
670
|
-
##
|
|
502
|
+
## Error Handling
|
|
671
503
|
|
|
672
504
|
```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';
|
|
505
|
+
app.use('/auth', auth.router());
|
|
506
|
+
app.use('/api', apiRouter);
|
|
507
|
+
app.use(auth.errorHandler()); // must be last
|
|
706
508
|
```
|
|
707
509
|
|
|
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
510
|
| Code | HTTP | Meaning |
|
|
715
511
|
|---|---|---|
|
|
716
512
|
| `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
|
|
717
|
-
| `
|
|
718
|
-
| `
|
|
719
|
-
| `
|
|
720
|
-
| `
|
|
721
|
-
| `
|
|
513
|
+
| `USER_NOT_FOUND` | 404 | User does not exist |
|
|
514
|
+
| `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
|
|
515
|
+
| `IDENTIFIER_NOT_FOUND` | 404 | Referenced identifier ID does not exist or belongs to another user |
|
|
516
|
+
| `IDENTIFIER_ALREADY_EXISTS` | 409 | Identifier value already taken by another user |
|
|
517
|
+
| `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
|
|
518
|
+
| `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
|
|
519
|
+
| `UNAUTHORIZED` | 401 | No valid token or session not found |
|
|
722
520
|
| `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
|
-
```
|
|
521
|
+
| `INVALID_ROLE` | 400 | Role not in `validRoles` |
|
|
522
|
+
| `VALIDATION_ERROR` | 400 | Missing or invalid input |
|
|
523
|
+
| `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` config |
|
|
736
524
|
|
|
737
525
|
### Extending `SentriError`
|
|
738
526
|
|
|
739
527
|
```typescript
|
|
740
528
|
import { SentriError } from 'sentri';
|
|
741
529
|
|
|
742
|
-
|
|
530
|
+
class NotFoundError extends SentriError {
|
|
743
531
|
constructor(resource: string) {
|
|
744
532
|
super('NOT_FOUND', `${resource} not found`, 404);
|
|
745
533
|
}
|
|
746
534
|
}
|
|
747
535
|
|
|
536
|
+
// Caught automatically by auth.errorHandler()
|
|
748
537
|
app.get('/items/:id', auth.protect(), async (req, res) => {
|
|
749
|
-
const item = await db.items.findById(req.params
|
|
538
|
+
const item = await db.items.findById(req.params.id);
|
|
750
539
|
if (!item) throw new NotFoundError('Item');
|
|
751
540
|
res.json(item);
|
|
752
541
|
});
|
|
@@ -754,39 +543,74 @@ app.get('/items/:id', auth.protect(), async (req, res) => {
|
|
|
754
543
|
|
|
755
544
|
---
|
|
756
545
|
|
|
757
|
-
##
|
|
546
|
+
## Request Idempotency
|
|
758
547
|
|
|
759
|
-
|
|
548
|
+
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
549
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
550
|
+
### Via `createAuthServer()` (recommended)
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
const auth = createAuthServer({
|
|
554
|
+
validRoles: ['user', 'admin'] as const,
|
|
555
|
+
db: { connectionString: process.env.DATABASE_URL! },
|
|
556
|
+
redisUrl: process.env.REDIS_URL, // omit for in-memory cache
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Mount before your routes
|
|
560
|
+
app.use(auth.idempotencyMiddleware());
|
|
561
|
+
|
|
562
|
+
// Override TTL or methods
|
|
563
|
+
app.use(auth.idempotencyMiddleware({ ttl: 60_000 }));
|
|
564
|
+
```
|
|
766
565
|
|
|
767
|
-
|
|
566
|
+
When `redisUrl` is set in server config, the middleware automatically uses Redis — no extra config needed.
|
|
768
567
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
-
|
|
775
|
-
|
|
568
|
+
### Standalone (without createAuthServer)
|
|
569
|
+
|
|
570
|
+
```typescript
|
|
571
|
+
import { createIdempotencyMiddleware } from 'sentri';
|
|
572
|
+
|
|
573
|
+
// In-memory (single process)
|
|
574
|
+
app.use(createIdempotencyMiddleware({ ttl: 300_000 }));
|
|
575
|
+
|
|
576
|
+
// Redis (multi-process)
|
|
577
|
+
app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### Options
|
|
581
|
+
|
|
582
|
+
| Option | Default | Description |
|
|
583
|
+
|---|---|---|
|
|
584
|
+
| `ttl` | `300_000` | Cache TTL in milliseconds |
|
|
585
|
+
| `header` | `'X-Idempotency-Key'` | Header name to read the key from |
|
|
586
|
+
| `methods` | `['POST','PUT','PATCH']` | HTTP methods to apply idempotency to |
|
|
587
|
+
| `maxSize` | `10_000` | Max in-memory entries (ignored when `redisUrl` is set) |
|
|
588
|
+
| `redisUrl` | — | Redis connection URL for multi-process cache |
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## Migration Guide
|
|
776
593
|
|
|
777
|
-
###
|
|
594
|
+
### 4.0.0 Breaking Changes
|
|
778
595
|
|
|
779
596
|
| What changed | Action required |
|
|
780
597
|
|---|---|
|
|
781
|
-
| `
|
|
782
|
-
| `
|
|
598
|
+
| `sentri_users` no longer has an `identifier` column | Drop and recreate tables — identities now live in `sentri_identifiers` |
|
|
599
|
+
| New `sentri_identifiers` table | Created automatically by `auth.migrate()` |
|
|
600
|
+
| `RegisterInput.identifier` → `RegisterInput.identifiers` (array) | Update register calls to pass `identifiers: [{ type, value }]` |
|
|
601
|
+
| `AuthUser` now includes `identifierType` | Update any code reading `req.user` to expect this new field |
|
|
602
|
+
| `PATCH /me/identifier` removed | Use `PUT /me/identifiers` for updates, `PATCH /me/identifiers/primary` for primary change |
|
|
603
|
+
| `changeIdentifier()` removed from `ServerAuthClient` | Use `bulkUpdateIdentifiers()` or `changePrimaryIdentifier()` |
|
|
604
|
+
| New error codes: `IDENTIFIER_NOT_FOUND`, `IDENTIFIER_ALREADY_EXISTS` | Handle these in your error handlers as needed |
|
|
783
605
|
|
|
784
|
-
###
|
|
606
|
+
### 3.0.0 Breaking Changes
|
|
785
607
|
|
|
786
608
|
| What changed | Action required |
|
|
787
609
|
|---|---|
|
|
788
|
-
| `
|
|
789
|
-
| `
|
|
790
|
-
| `
|
|
791
|
-
| `
|
|
792
|
-
| `
|
|
610
|
+
| `createAuth` now requires `mode: 'server'` or `mode: 'client'` | Add `mode: 'server'` to existing configs |
|
|
611
|
+
| `adapter` field removed — Sentri owns the schema | Remove adapter, add `dialect` (Kysely Dialect) |
|
|
612
|
+
| `algorithm` now supports `'RS256' \| 'RS384' \| 'RS512'` | Optional — HS256 still default |
|
|
613
|
+
| `AuthError` renamed to `SentriError` | Update imports: `import { SentriError } from 'sentri'` |
|
|
614
|
+
| `AUTH_ERROR_STATUS` renamed to `SENTRI_ERROR_STATUS` | Update references |
|
|
615
|
+
| `npx sentri generate` removed | Run `await auth.migrate()` at startup instead |
|
|
616
|
+
| Templates directory removed | No longer needed |
|