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