sentri 4.1.1 → 5.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 CHANGED
@@ -1,16 +1,35 @@
1
1
  # sentri
2
2
 
3
- Auth and authorization library for Express. Supports two modes:
3
+ [![npm version](https://img.shields.io/npm/v/sentri.svg)](https://www.npmjs.com/package/sentri) [![license](https://img.shields.io/npm/l/sentri.svg)](LICENSE) [![build](https://img.shields.io/github/actions/workflow/status/rizzzdev/sentri/build.yml?branch=main)](https://github.com/rizzzdev/sentri/actions)
4
+
5
+ Auth and authorization library for Node.js — Express, Fastify, Hono, Elysia, and Koa. Supports two modes:
4
6
 
5
7
  - **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
8
  - **Client mode** — used by other apps to validate tokens issued by the auth server, without a database.
7
9
 
10
+ ## Subpath exports
11
+
12
+ | Import | Description |
13
+ | ---------------- | ---------------------------------------------------------- |
14
+ | `sentri` | Full package re-export (backward compat) |
15
+ | `sentri/core` | Framework-agnostic types, `SentriError`, `SentriLogger` |
16
+ | `sentri/express` | Express adapter — `createAuthExpress`, middleware, router |
17
+ | `sentri/fastify` | Fastify adapter — `createAuthFastify`, preHandlers, plugin |
18
+ | `sentri/hono` | Hono adapter — `createAuthHono`, middleware, router |
19
+ | `sentri/elysia` | Elysia adapter — `createAuthElysia`, middleware, router |
20
+ | `sentri/koa` | Koa adapter — `createAuthKoa`, middleware, router |
21
+
8
22
  ---
9
23
 
10
24
  ## Table of Contents
11
25
 
12
26
  - [Installation](#installation)
13
27
  - [Quick Start](#quick-start)
28
+ - [Express](#express-server-mode-recommended)
29
+ - [Fastify](#fastify-server-mode)
30
+ - [Hono](#hono-server-mode)
31
+ - [Elysia](#elysia-server-mode)
32
+ - [Client mode](#client-mode-1)
14
33
  - [Server Mode](#server-mode)
15
34
  - [Client Mode](#client-mode)
16
35
  - [SSO Flow](#sso-flow)
@@ -32,9 +51,15 @@ Auth and authorization library for Express. Supports two modes:
32
51
  npm install sentri
33
52
  ```
34
53
 
35
- `kysely` and `pg` are bundled with sentri — no separate installation needed for PostgreSQL.
54
+ `kysely` is bundled with sentri — no separate installation needed for Kysely. However, you must install the driver for your database of choice.
36
55
 
37
- For other databases, install the driver:
56
+ For PostgreSQL:
57
+
58
+ ```bash
59
+ npm install pg
60
+ ```
61
+
62
+ For other databases:
38
63
 
39
64
  ```bash
40
65
  # MySQL
@@ -44,21 +69,43 @@ npm install mysql2
44
69
  npm install better-sqlite3
45
70
  ```
46
71
 
47
- **Peer dependency:** `express >= 4.0.0`
72
+ **Peer dependencies** (install only what you use):
73
+
74
+ ```bash
75
+ # Express
76
+ npm install express
77
+
78
+ # Fastify
79
+ npm install fastify @fastify/cookie
80
+
81
+ # Hono
82
+ npm install hono
83
+
84
+ # Elysia
85
+ npm install elysia
86
+ ```
48
87
 
49
88
  ---
50
89
 
51
90
  ## Quick Start
52
91
 
53
- ### Server Mode PostgreSQL (recommended)
92
+ ### Express (server mode, recommended)
54
93
 
55
94
  ```typescript
56
- import express from 'express';
57
- import { createAuthServer } from 'sentri';
95
+ import express from "express";
96
+ import { createAuthExpress } from "sentri/express";
58
97
 
59
- const auth = createAuthServer({
60
- validRoles: ['user', 'admin'] as const,
61
- db: { connectionString: process.env.DATABASE_URL! },
98
+ import { PostgresDialect } from "kysely";
99
+ import pg from "pg";
100
+
101
+ const { Pool } = pg;
102
+
103
+ const auth = createAuthExpress({
104
+ mode: "server",
105
+ validRoles: ["user", "admin"] as const,
106
+ dialect: new PostgresDialect({
107
+ pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
108
+ }),
62
109
  });
63
110
 
64
111
  const app = express();
@@ -66,35 +113,190 @@ app.use(express.json());
66
113
  app.use(auth.idempotencyMiddleware());
67
114
 
68
115
  await auth.migrate();
69
- app.use('/auth', auth.router());
116
+ app.use("/auth", auth.router());
70
117
 
71
- app.get('/me', auth.protect(), (req, res) => res.json(req.user));
118
+ app.get("/me", auth.protect(), (req, res) => res.json(req.user));
72
119
  app.use(auth.errorHandler());
73
120
  app.listen(3000);
74
121
  ```
75
122
 
76
123
  `createAuthServer()` generates an RSA-2048 key pair at startup and enables `GET /keys` automatically.
77
124
 
125
+ For detailed Express docs see [src/adapters/express/README.md](src/adapters/express/README.md).
126
+
127
+ ### Fastify (server mode)
128
+
129
+ ```typescript
130
+ import Fastify from "fastify";
131
+ import cookie from "@fastify/cookie";
132
+ import { createAuthFastify } from "sentri/fastify";
133
+
134
+ import { PostgresDialect } from "kysely";
135
+ import pg from "pg";
136
+
137
+ const { Pool } = pg;
138
+
139
+ const auth = createAuthFastify({
140
+ mode: "server",
141
+ validRoles: ["user", "admin"] as const,
142
+ dialect: new PostgresDialect({
143
+ pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
144
+ }),
145
+ });
146
+
147
+ await auth.migrate();
148
+
149
+ const app = Fastify();
150
+ await app.register(cookie);
151
+ await app.register(auth.plugin(), { prefix: "/auth" });
152
+ app.setErrorHandler(auth.errorHandler());
153
+
154
+ app.get("/me", { preHandler: auth.protect() }, async (request, reply) => {
155
+ reply.send(request.user);
156
+ });
157
+
158
+ await app.listen({ port: 3000 });
159
+ ```
160
+
161
+ For detailed Fastify docs see [src/adapters/fastify/README.md](src/adapters/fastify/README.md).
162
+
163
+ ### Hono (server mode)
164
+
165
+ ```typescript
166
+ import { Hono } from "hono";
167
+ import { createAuthHono } from "sentri/hono";
168
+ import type { SentriHonoEnv } from "sentri/hono";
169
+
170
+ import { PostgresDialect } from "kysely";
171
+ import pg from "pg";
172
+
173
+ const { Pool } = pg;
174
+
175
+ const auth = createAuthHono({
176
+ mode: "server",
177
+ validRoles: ["user", "admin"] as const,
178
+ dialect: new PostgresDialect({
179
+ pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
180
+ }),
181
+ });
182
+
183
+ await auth.migrate();
184
+
185
+ const app = new Hono<SentriHonoEnv>();
186
+
187
+ app.route("/auth", auth.router());
188
+
189
+ app.get("/me", auth.protect(), (c) => c.json(c.get("user")));
190
+
191
+ app.onError(auth.errorHandler());
192
+
193
+ export default app;
194
+ ```
195
+
196
+ `createAuthHono` generates an RSA-2048 key pair at startup and enables `GET /keys` automatically. Works with Node.js, Cloudflare Workers, Bun, and Deno — use **client mode** for edge runtimes that cannot reach a PostgreSQL database.
197
+
198
+ For detailed Hono docs see [src/adapters/hono/README.md](src/adapters/hono/README.md).
199
+
200
+ ### Elysia (server mode)
201
+
202
+ ```typescript
203
+ import { Elysia } from "elysia";
204
+ import { createAuthElysia } from "sentri/elysia";
205
+
206
+ import { PostgresDialect } from "kysely";
207
+ import pg from "pg";
208
+
209
+ const { Pool } = pg;
210
+
211
+ const auth = createAuthElysia({
212
+ mode: "server",
213
+ validRoles: ["user", "admin"] as const,
214
+ dialect: new PostgresDialect({
215
+ pool: new Pool({ connectionString: process.env.DATABASE_URL! }),
216
+ }),
217
+ });
218
+
219
+ await auth.migrate();
220
+
221
+ const app = new Elysia()
222
+ .onError(auth.errorHandler())
223
+ .group("/auth", (app) => app.use(auth.router()))
224
+ .use(auth.protect())
225
+ .get("/me", ({ user }) => user);
226
+
227
+ app.listen(3000);
228
+ ```
229
+
230
+ For detailed Elysia docs see [src/adapters/elysia/README.md](src/adapters/elysia/README.md).
231
+
232
+ ### Koa (server mode)
233
+
234
+ ```typescript
235
+ import Koa from "koa";
236
+ import Router from "@koa/router";
237
+ import bodyParser from "koa-bodyparser";
238
+ import { createAuthKoa } from "sentri/koa";
239
+
240
+ const auth = createAuthKoa({
241
+ mode: "server",
242
+ validRoles: ["user", "admin"] as const,
243
+ db: { connectionString: process.env.DATABASE_URL! },
244
+ });
245
+
246
+ await auth.migrate();
247
+
248
+ const app = new Koa();
249
+ app.use(bodyParser());
250
+ app.use(auth.errorHandler());
251
+
252
+ const rootRouter = new Router();
253
+ rootRouter.use("/auth", auth.router().routes(), auth.router().allowedMethods());
254
+
255
+ rootRouter.get("/me", auth.protect(), (ctx) => {
256
+ ctx.body = ctx.state.user;
257
+ });
258
+
259
+ app.use(rootRouter.routes());
260
+ app.use(rootRouter.allowedMethods());
261
+
262
+ app.listen(3000);
263
+ ```
264
+
265
+ For detailed Koa docs see [src/adapters/koa/README.md](src/adapters/koa/README.md).
266
+
267
+ ### Client mode (any framework, any server)
268
+
269
+ ```typescript
270
+ import { createAuthExpress } from "sentri/express";
271
+
272
+ const auth = createAuthExpress({
273
+ mode: "client",
274
+ keyUri: "https://auth.myapp.com/auth/keys",
275
+ });
276
+ ```
277
+
78
278
  ### Server Mode — Custom Dialect
79
279
 
80
280
  ```typescript
81
- import express from 'express';
82
- import { createAuth } from 'sentri';
83
- import { PostgresDialect } from 'kysely'; // kysely is bundled, no install needed
84
- import { Pool } from 'pg'; // pg is bundled, no install needed
281
+ import express from "express";
282
+ import { createAuth } from "sentri";
283
+ import { PostgresDialect } from "kysely"; // kysely is bundled, no install needed
284
+ import { Pool } from "pg"; // pg is bundled, no install needed
85
285
 
86
286
  const auth = createAuth({
87
- mode: 'server',
88
- dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL }) }),
89
- secret: process.env.JWT_PRIVATE_KEY!, // RSA private key PEM (RS256) or plain string (HS256)
90
- algorithm: 'RS256',
91
- validRoles: ['user', 'admin'] as const,
287
+ mode: "server",
288
+ dialect: new PostgresDialect({
289
+ pool: new Pool({ connectionString: process.env.DATABASE_URL }),
290
+ }),
291
+ secret: process.env.JWT_PRIVATE_KEY!, // RSA private key PEM (RS256) or plain string (HS256)
292
+ algorithm: "RS256",
293
+ validRoles: ["user", "admin"] as const,
92
294
  });
93
295
 
94
296
  const app = express();
95
297
  app.use(express.json());
96
298
  await auth.migrate();
97
- app.use('/auth', auth.router());
299
+ app.use("/auth", auth.router());
98
300
  app.use(auth.errorHandler());
99
301
  app.listen(3000);
100
302
  ```
@@ -102,17 +304,22 @@ app.listen(3000);
102
304
  ### Client Mode (Other Apps)
103
305
 
104
306
  ```typescript
105
- import express from 'express';
106
- import { createAuth } from 'sentri';
307
+ import express from "express";
308
+ import { createAuth } from "sentri";
107
309
 
108
310
  const auth = createAuth({
109
- mode: 'client',
110
- keyUri: 'https://auth.myapp.com/auth/keys',
311
+ mode: "client",
312
+ keyUri: "https://auth.myapp.com/auth/keys",
111
313
  });
112
314
 
113
315
  const app = express();
114
- app.get('/products', auth.protect(), auth.authorize('admin'), handler);
115
- app.get('/orders', auth.protect(), auth.permit(req => req.user!.id === req.params.userId), handler);
316
+ app.get("/products", auth.protect(), auth.authorize("admin"), handler);
317
+ app.get(
318
+ "/orders",
319
+ auth.protect(),
320
+ auth.permit((req) => req.user!.id === req.params.userId),
321
+ handler,
322
+ );
116
323
 
117
324
  app.use(auth.errorHandler());
118
325
  ```
@@ -126,10 +333,10 @@ Server mode manages users, sessions, and tokens entirely within Sentri. The user
126
333
  ### `createAuthServer(options)` — PostgreSQL shortcut
127
334
 
128
335
  ```typescript
129
- import { createAuthServer } from 'sentri';
336
+ import { createAuthServer } from "sentri/express";
130
337
 
131
338
  const auth = createAuthServer({
132
- validRoles: ['user', 'admin'] as const,
339
+ validRoles: ["user", "admin"] as const,
133
340
 
134
341
  // Connection string
135
342
  db: { connectionString: process.env.DATABASE_URL!, max: 10 },
@@ -138,37 +345,48 @@ const auth = createAuthServer({
138
345
  // db: { host: 'localhost', port: 5432, database: 'mydb', user: 'app', password: 'secret' },
139
346
 
140
347
  // Optional
141
- accessExpiresIn: '15m',
142
- refreshExpiresIn: '7d',
348
+ accessExpiresIn: "15m",
349
+ refreshExpiresIn: "7d",
143
350
  saltRounds: 12,
144
351
  apiKey: process.env.REGISTER_API_KEY,
145
- redisUrl: process.env.REDIS_URL, // enables Redis-backed idempotency cache
352
+ redisUrl: process.env.REDIS_URL, // enables Redis-backed idempotency cache
146
353
  });
147
354
  ```
148
355
 
149
356
  ### `createAuth(config)` — Full config
150
357
 
151
358
  ```typescript
359
+ import { createAuth } from "sentri/express";
360
+
152
361
  createAuth({
153
- mode: 'server',
154
- dialect, // required — Kysely Dialect
155
- secret: process.env.JWT_SECRET!, // required — RSA private key PEM (RS256) or string (HS256)
156
- validRoles: ['user', 'admin'] as const, // required
157
-
158
- algorithm: 'RS256', // default: 'HS256' | also: 'HS384','HS512','RS384','RS512'
159
- accessExpiresIn: '15m', // default: '15m'
160
- refreshExpiresIn: '7d', // default: '7d'
161
- saltRounds: 12, // default: 12 (bcrypt rounds, 10–31)
162
- apiKey: process.env.REGISTER_API_KEY, // restricts POST /register
163
- redisUrl: process.env.REDIS_URL, // Redis URL for idempotency cache
164
- cookie: { secure: true }, // httpOnly refresh token cookie
165
- accessCookie: { secure: true }, // non-httpOnly access token cookie (SPA)
362
+ mode: "server",
363
+ dialect, // required — Kysely Dialect
364
+ secret: process.env.JWT_SECRET!, // required — RSA private key PEM (RS256) or string (HS256)
365
+ validRoles: ["user", "admin"] as const, // required
366
+
367
+ algorithm: "RS256", // default: 'HS256' | also: 'HS384','HS512','RS384','RS512'
368
+ accessExpiresIn: "15m", // default: '15m'
369
+ refreshExpiresIn: "7d", // default: '7d'
370
+ saltRounds: 12, // default: 12 (bcrypt rounds, 10–31)
371
+ apiKey: process.env.REGISTER_API_KEY, // restricts POST /register
372
+ redisUrl: process.env.REDIS_URL, // Redis URL for idempotency cache
373
+ cookie: { secure: true }, // httpOnly refresh token cookie
374
+ accessCookie: { secure: true }, // non-httpOnly access token cookie (SPA)
166
375
  hooks: { onLogin, onFailedLogin, onLogout },
167
- isTokenRevoked: async (sessionId) => await redis.sismember('revoked', sessionId),
168
- router: { // override built-in service functions
169
- login, register, refresh, logout, logoutAll, assignRoles,
170
- bulkCreateIdentifiers, bulkUpdateIdentifiers, bulkDeleteIdentifiers,
171
- changePrimaryIdentifier, changePassword,
376
+ isTokenRevoked: async (sessionId) =>
377
+ await redis.sismember("revoked", sessionId),
378
+ router: {
379
+ // override built-in service functions
380
+ login,
381
+ register,
382
+ refresh,
383
+ logout,
384
+ logoutAll,
385
+ assignRoles,
386
+ bulkCreateIdentifiers,
387
+ bulkUpdateIdentifiers,
388
+ bulkDeleteIdentifiers,
389
+ changePassword,
172
390
  },
173
391
  });
174
392
  ```
@@ -181,9 +399,10 @@ await auth.migrate();
181
399
  ```
182
400
 
183
401
  Creates three tables:
402
+
184
403
  - `sentri_users` — id, password_hash, roles (JSON), created_at
185
404
  - `sentri_sessions` — id, user_id, expires_at, created_at
186
- - `sentri_identifiers` — id, user_id, type, value (globally unique), is_primary, created_at
405
+ - `sentri_identifiers` — id, user_id, type, value (globally unique), created_at
187
406
 
188
407
  ---
189
408
 
@@ -193,9 +412,9 @@ Client mode has no database. It fetches the auth server's public key and validat
193
412
 
194
413
  ```typescript
195
414
  createAuth({
196
- mode: 'client',
197
- keyUri: 'https://auth.myapp.com/auth/keys', // required
198
- validRoles: ['admin', 'user'], // optional — TypeScript type safety only
415
+ mode: "client",
416
+ keyUri: "https://auth.myapp.com/auth/keys", // required
417
+ validRoles: ["admin", "user"], // optional — TypeScript type safety only
199
418
  });
200
419
  ```
201
420
 
@@ -231,11 +450,11 @@ Client apps point `keyUri` at this endpoint and receive the public key automatic
231
450
 
232
451
  Each user can have multiple identifiers — email, username, phone number, or any custom type. All identifier values are **globally unique** regardless of type.
233
452
 
234
- 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.
453
+ Login accepts any identifier value Sentri searches all types automatically. No concept of "primary" exists; the JWT payload only contains `{ id, roles, sessionId }`.
235
454
 
236
455
  ### Registration
237
456
 
238
- Provide at least one identifier. The first entry becomes the primary.
457
+ Provide at least one identifier.
239
458
 
240
459
  ```
241
460
  POST /register
@@ -252,6 +471,7 @@ Content-Type: application/json
252
471
  ```
253
472
 
254
473
  **Response:**
474
+
255
475
  ```json
256
476
  {
257
477
  "error": false,
@@ -260,12 +480,10 @@ Content-Type: application/json
260
480
  "data": {
261
481
  "user": {
262
482
  "id": "uuid",
263
- "identifier": "rizz@example.com",
264
- "identifierType": "email",
265
483
  "roles": ["user"],
266
484
  "identifiers": [
267
- { "id": "uuid-1", "type": "email", "value": "rizz@example.com", "isPrimary": true },
268
- { "id": "uuid-2", "type": "username", "value": "rizz", "isPrimary": false }
485
+ { "id": "uuid-1", "type": "email", "value": "rizz@example.com" },
486
+ { "id": "uuid-2", "type": "username", "value": "rizz" }
269
487
  ]
270
488
  }
271
489
  }
@@ -323,18 +541,6 @@ Content-Type: application/json
323
541
 
324
542
  At least one identifier must remain after deletion.
325
543
 
326
- ### Change Primary Identifier
327
-
328
- ```
329
- PATCH /me/identifiers/primary
330
- Authorization: Bearer <token>
331
- Content-Type: application/json
332
-
333
- { "id": "uuid-2" }
334
- ```
335
-
336
- The new primary value will be embedded in the JWT on the next login or token refresh.
337
-
338
544
  ### Programmatic API
339
545
 
340
546
  ```typescript
@@ -357,9 +563,6 @@ await auth.bulkUpdateIdentifiers(userId, [{ id: 'uuid-2', type: 'username', valu
357
563
 
358
564
  // Delete identifiers
359
565
  await auth.bulkDeleteIdentifiers(userId, ['uuid-2']);
360
-
361
- // Change primary
362
- await auth.changePrimaryIdentifier(userId, 'uuid-3');
363
566
  ```
364
567
 
365
568
  ---
@@ -368,10 +571,10 @@ await auth.changePrimaryIdentifier(userId, 'uuid-3');
368
571
 
369
572
  ### `algorithm`
370
573
 
371
- | Value | Type | Use case |
372
- |---|---|---|
574
+ | Value | Type | Use case |
575
+ | --------- | ------------------- | ------------------------- |
373
576
  | `'HS256'` | Symmetric (default) | Single app, shared secret |
374
- | `'RS256'` | Asymmetric | SSO — enables `GET /keys` |
577
+ | `'RS256'` | Asymmetric | SSO — enables `GET /keys` |
375
578
 
376
579
  When using RS256, `secret` must be a valid RSA private key in PEM format. `createAuthServer()` generates the key pair automatically.
377
580
 
@@ -379,9 +582,9 @@ When using RS256, `secret` must be a valid RSA private key in PEM format. `creat
379
582
 
380
583
  ```typescript
381
584
  createAuth({
382
- mode: 'server',
383
- cookie: { secure: true }, // httpOnly refresh token
384
- accessCookie: { secure: true }, // non-httpOnly access token (readable by JS)
585
+ mode: "server",
586
+ cookie: { secure: true }, // httpOnly refresh token
587
+ accessCookie: { secure: true }, // non-httpOnly access token (readable by JS)
385
588
  });
386
589
  ```
387
590
 
@@ -393,23 +596,23 @@ After login, both cookies are set automatically. `protect()` reads the access to
393
596
 
394
597
  `auth.router()` mounts these endpoints:
395
598
 
396
- | Method | Path | Auth | Description |
397
- |---|---|---|---|
398
- | `POST` | `/register` | — | Create a user (requires `X-Api-Key` when `apiKey` is set) |
399
- | `POST` | `/login` | — | Authenticate, receive tokens |
400
- | `POST` | `/refresh` | — | Rotate refresh token |
401
- | `POST` | `/logout` | — | Invalidate current session |
402
- | `POST` | `/logout-all` | ✓ | Invalidate all sessions |
403
- | `GET` | `/me` | ✓ | Return authenticated user with all identifiers |
404
- | `POST` | `/me/identifiers` | ✓ self | Add identifiers in bulk |
405
- | `PUT` | `/me/identifiers` | ✓ self | Update identifiers in bulk |
406
- | `DELETE` | `/me/identifiers` | ✓ self | Delete identifiers in bulk |
407
- | `PATCH` | `/me/identifiers/primary` | ✓ self | Change primary identifier |
408
- | `PATCH` | `/me/password` | ✓ self | Change password revokes all sessions |
409
- | `POST` | `/users/:userId/roles` | admin | Assign roles to user |
410
- | `GET` | `/keys` | — | Public key in JWKS format (RS256 only) |
599
+ | Method | Path | Auth | Description |
600
+ | -------- | ---------------------- | ------- | --------------------------------------------------------- |
601
+ | `POST` | `/register` | — | Create a user (requires `X-Api-Key` when `apiKey` is set) |
602
+ | `POST` | `/login` | — | Authenticate, receive tokens |
603
+ | `POST` | `/refresh` | — | Rotate refresh token |
604
+ | `POST` | `/logout` | — | Invalidate current session |
605
+ | `POST` | `/logout-all` | ✓ | Invalidate all sessions |
606
+ | `GET` | `/me` | ✓ | Return authenticated user with all identifiers |
607
+ | `POST` | `/me/identifiers` | ✓ self | Add identifiers in bulk |
608
+ | `PUT` | `/me/identifiers` | ✓ self | Update identifiers in bulk |
609
+ | `DELETE` | `/me/identifiers` | ✓ self | Delete identifiers in bulk |
610
+ | `PATCH` | `/me/password` | ✓ self | Change password revokes all sessions |
611
+ | `POST` | `/users/:userId/roles` | ✓ admin | Assign roles to user |
612
+ | `GET` | `/keys` | | Public key in JWKS format (RS256 only) |
411
613
 
412
614
  All responses use the envelope:
615
+
413
616
  ```json
414
617
  { "error": false, "statusCode": 200, "message": "...", "data": { ... } }
415
618
  ```
@@ -432,45 +635,64 @@ Changing the password revokes all existing sessions. The user must log in again
432
635
 
433
636
  ### `auth.protect()`
434
637
 
435
- Verifies the JWT and sets `req.user`. In server mode, also performs silent token refresh when the access token expires.
638
+ Verifies the JWT and sets the user on the request context. In server mode, also performs silent token refresh when the access token expires.
639
+
640
+ Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
436
641
 
437
642
  ```typescript
438
- app.get('/dashboard', auth.protect(), (req, res) => res.json(req.user));
439
- // req.user: { id, identifier, identifierType, roles, identifiers? }
440
- ```
643
+ // Express
644
+ app.get("/dashboard", auth.protect(), (req, res) => res.json(req.user));
645
+ // req.user: { id, roles }
646
+
647
+ // Fastify
648
+ app.get(
649
+ "/dashboard",
650
+ { preHandler: auth.protect() },
651
+ async (request) => request.user,
652
+ );
441
653
 
442
- Token is read from `Authorization: Bearer <token>` header or `access_token` cookie.
654
+ // Hono
655
+ app.get("/dashboard", auth.protect(), (c) => c.json(c.get("user")));
656
+ ```
443
657
 
444
658
  ### `auth.authorize(...roles)`
445
659
 
446
660
  Role-based access — must follow `protect()`.
447
661
 
448
662
  ```typescript
449
- app.delete('/posts/:id', auth.protect(), auth.authorize('admin'), handler);
663
+ // Express / Fastify preHandler / Hono — same API
664
+ app.delete("/posts/:id", auth.protect(), auth.authorize("admin"), handler);
450
665
  ```
451
666
 
452
667
  ### `auth.permit(check | options)`
453
668
 
454
- Resource-level permission — must follow `protect()`.
669
+ Resource-level permission — must follow `protect()`. The check function receives the request context object of each framework.
455
670
 
456
671
  ```typescript
457
- // Ownership check
458
- app.put('/users/:id', auth.protect(),
672
+ // Express
673
+ app.put(
674
+ "/users/:id",
675
+ auth.protect(),
459
676
  auth.permit((req) => req.user!.id === req.params.id),
460
677
  handler,
461
678
  );
462
679
 
463
- // Role bypass + ownership check
464
- app.delete('/posts/:id', auth.protect(),
465
- auth.permit({
466
- roles: ['admin'],
467
- check: async (req) => {
468
- const post = await db.posts.findById(req.params.id);
469
- return post?.authorId === req.user!.id;
470
- },
471
- }),
680
+ // Hono
681
+ app.put(
682
+ "/users/:id",
683
+ auth.protect(),
684
+ auth.permit((c) => c.get("user")!.id === c.req.param("id")),
472
685
  handler,
473
686
  );
687
+
688
+ // Role bypass + ownership check
689
+ auth.permit({
690
+ roles: ["admin"],
691
+ check: async (req) => {
692
+ const post = await db.posts.findById(req.params.id);
693
+ return post?.authorId === req.user!.id;
694
+ },
695
+ });
474
696
  ```
475
697
 
476
698
  ---
@@ -483,7 +705,7 @@ Available on `ServerAuthClient` only:
483
705
  const auth = createAuth({ mode: 'server', ... });
484
706
 
485
707
  // Sign
486
- const accessToken = auth.signAccessToken({ id, identifier, identifierType, roles });
708
+ const accessToken = auth.signAccessToken({ id, roles });
487
709
  const refreshToken = auth.signRefreshToken(sessionId);
488
710
 
489
711
  // Verify
@@ -503,41 +725,49 @@ const token = auth.getCurrentAccessToken(req);
503
725
  ## Error Handling
504
726
 
505
727
  ```typescript
506
- app.use('/auth', auth.router());
507
- app.use('/api', apiRouter);
508
- app.use(auth.errorHandler()); // must be last
509
- ```
510
-
511
- | Code | HTTP | Meaning |
512
- |---|---|---|
513
- | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
514
- | `USER_NOT_FOUND` | 404 | User does not exist |
515
- | `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
516
- | `IDENTIFIER_NOT_FOUND` | 404 | Referenced identifier ID does not exist or belongs to another user |
517
- | `IDENTIFIER_ALREADY_EXISTS` | 409 | Identifier value already taken by another user |
518
- | `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
519
- | `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
520
- | `UNAUTHORIZED` | 401 | No valid token or session not found |
521
- | `FORBIDDEN` | 403 | Authenticated but missing required role |
522
- | `INVALID_ROLE` | 400 | Role not in `validRoles` |
523
- | `VALIDATION_ERROR` | 400 | Missing or invalid input |
524
- | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` config |
728
+ // Express — must be last
729
+ app.use("/auth", auth.router());
730
+ app.use("/api", apiRouter);
731
+ app.use(auth.errorHandler());
732
+
733
+ // Fastify
734
+ app.setErrorHandler(auth.errorHandler());
735
+
736
+ // Hono mount via onError
737
+ app.route("/auth", auth.router());
738
+ app.onError(auth.errorHandler());
739
+ ```
740
+
741
+ | Code | HTTP | Meaning |
742
+ | --------------------------- | ---- | ------------------------------------------------------------------ |
743
+ | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
744
+ | `USER_NOT_FOUND` | 404 | User does not exist |
745
+ | `USER_ALREADY_EXISTS` | 409 | Duplicate identifier at registration |
746
+ | `IDENTIFIER_NOT_FOUND` | 404 | Referenced identifier ID does not exist or belongs to another user |
747
+ | `IDENTIFIER_ALREADY_EXISTS` | 409 | Identifier value already taken by another user |
748
+ | `TOKEN_EXPIRED` | 401 | JWT `exp` is in the past |
749
+ | `TOKEN_INVALID` | 401 | Bad signature or malformed JWT |
750
+ | `UNAUTHORIZED` | 401 | No valid token or session not found |
751
+ | `FORBIDDEN` | 403 | Authenticated but missing required role |
752
+ | `INVALID_ROLE` | 400 | Role not in `validRoles` |
753
+ | `VALIDATION_ERROR` | 400 | Missing or invalid input |
754
+ | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` config |
525
755
 
526
756
  ### Extending `SentriError`
527
757
 
528
758
  ```typescript
529
- import { SentriError } from 'sentri';
759
+ import { SentriError } from "sentri";
530
760
 
531
761
  class NotFoundError extends SentriError {
532
762
  constructor(resource: string) {
533
- super('NOT_FOUND', `${resource} not found`, 404);
763
+ super("NOT_FOUND", `${resource} not found`, 404);
534
764
  }
535
765
  }
536
766
 
537
767
  // Caught automatically by auth.errorHandler()
538
- app.get('/items/:id', auth.protect(), async (req, res) => {
768
+ app.get("/items/:id", auth.protect(), async (req, res) => {
539
769
  const item = await db.items.findById(req.params.id);
540
- if (!item) throw new NotFoundError('Item');
770
+ if (!item) throw new NotFoundError("Item");
541
771
  res.json(item);
542
772
  });
543
773
  ```
@@ -552,9 +782,9 @@ Repeat requests with the same `X-Idempotency-Key` header receive the cached resp
552
782
 
553
783
  ```typescript
554
784
  const auth = createAuthServer({
555
- validRoles: ['user', 'admin'] as const,
785
+ validRoles: ["user", "admin"] as const,
556
786
  db: { connectionString: process.env.DATABASE_URL! },
557
- redisUrl: process.env.REDIS_URL, // omit for in-memory cache
787
+ redisUrl: process.env.REDIS_URL, // omit for in-memory cache
558
788
  });
559
789
 
560
790
  // Mount before your routes
@@ -569,24 +799,24 @@ When `redisUrl` is set in server config, the middleware automatically uses Redis
569
799
  ### Standalone (without createAuthServer)
570
800
 
571
801
  ```typescript
572
- import { createIdempotencyMiddleware } from 'sentri';
802
+ import { createIdempotencyMiddleware } from "sentri";
573
803
 
574
804
  // In-memory (single process)
575
805
  app.use(createIdempotencyMiddleware({ ttl: 300_000 }));
576
806
 
577
807
  // Redis (multi-process)
578
- app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
808
+ app.use(createIdempotencyMiddleware({ redisUrl: "redis://localhost:6379" }));
579
809
  ```
580
810
 
581
811
  ### Options
582
812
 
583
- | Option | Default | Description |
584
- |---|---|---|
585
- | `ttl` | `300_000` | Cache TTL in milliseconds |
586
- | `header` | `'X-Idempotency-Key'` | Header name to read the key from |
587
- | `methods` | `['POST','PUT','PATCH']` | HTTP methods to apply idempotency to |
588
- | `maxSize` | `10_000` | Max in-memory entries (ignored when `redisUrl` is set) |
589
- | `redisUrl` | — | Redis connection URL for multi-process cache |
813
+ | Option | Default | Description |
814
+ | ---------- | ------------------------ | ------------------------------------------------------ |
815
+ | `ttl` | `300_000` | Cache TTL in milliseconds |
816
+ | `header` | `'X-Idempotency-Key'` | Header name to read the key from |
817
+ | `methods` | `['POST','PUT','PATCH']` | HTTP methods to apply idempotency to |
818
+ | `maxSize` | `10_000` | Max in-memory entries (ignored when `redisUrl` is set) |
819
+ | `redisUrl` | — | Redis connection URL for multi-process cache |
590
820
 
591
821
  ---
592
822
 
@@ -599,22 +829,24 @@ Sentri produces structured JSON log entries for every auth event. Logging is **o
599
829
  Pass any object that implements `{ info, warn, error }` via the `logger` field in your config.
600
830
 
601
831
  **pino** (recommended for production):
832
+
602
833
  ```typescript
603
- import pino from 'pino';
834
+ import pino from "pino";
604
835
 
605
836
  const auth = createAuth({
606
- mode: 'server',
837
+ mode: "server",
607
838
  // ...other config
608
839
  logger: pino(),
609
840
  });
610
841
  ```
611
842
 
612
843
  **winston**:
844
+
613
845
  ```typescript
614
- import winston from 'winston';
846
+ import winston from "winston";
615
847
 
616
848
  const auth = createAuth({
617
- mode: 'server',
849
+ mode: "server",
618
850
  // ...other config
619
851
  logger: winston.createLogger({
620
852
  transports: [new winston.transports.Console()],
@@ -623,19 +855,21 @@ const auth = createAuth({
623
855
  ```
624
856
 
625
857
  **console** (zero setup, good for development):
858
+
626
859
  ```typescript
627
860
  const auth = createAuth({
628
- mode: 'server',
861
+ mode: "server",
629
862
  // ...other config
630
863
  logger: console,
631
864
  });
632
865
  ```
633
866
 
634
867
  Works identically in **client mode**:
868
+
635
869
  ```typescript
636
870
  const auth = createAuth({
637
- mode: 'client',
638
- keyUri: 'https://auth.myapp.com/auth/keys',
871
+ mode: "client",
872
+ keyUri: "https://auth.myapp.com/auth/keys",
639
873
  logger: pino(),
640
874
  });
641
875
  ```
@@ -648,7 +882,7 @@ By default every log entry contains `"service": "sentri"`. Override it with `log
648
882
  const auth = createAuth({
649
883
  // ...
650
884
  logger: pino(),
651
- loggerService: 'auth-service',
885
+ loggerService: "auth-service",
652
886
  });
653
887
  ```
654
888
 
@@ -657,17 +891,18 @@ const auth = createAuth({
657
891
  If you mount a request-ID middleware **before** Sentri, the `requestId` is automatically included in every log entry for that request:
658
892
 
659
893
  ```typescript
660
- import { randomUUID } from 'crypto';
894
+ import { randomUUID } from "crypto";
661
895
 
662
896
  app.use((req, _res, next) => {
663
897
  req.requestId = randomUUID();
664
898
  next();
665
899
  });
666
900
 
667
- app.use('/auth', auth.router());
901
+ app.use("/auth", auth.router());
668
902
  ```
669
903
 
670
904
  Sample pino output:
905
+
671
906
  ```json
672
907
  {"level":30,"time":1719484800000,"service":"sentri","event":"auth.login.success","userId":"usr_abc","duration_ms":42,"requestId":"d4e5f6"}
673
908
  {"level":40,"time":1719484801000,"service":"sentri","event":"auth.authorize.denied","userId":"usr_abc","userRoles":["user"],"requiredRoles":["admin"],"requestId":"d4e5f7"}
@@ -675,47 +910,52 @@ Sample pino output:
675
910
 
676
911
  ### Log events
677
912
 
678
- | Event | Level | Emitted by | Key fields |
679
- |-------|-------|------------|-----------|
680
- | `auth.protect.success` | info | `protect()` | `userId`, `mode` |
681
- | `auth.protect.failure` | warn | `protect()` | `errorCode`, `mode` |
682
- | `auth.protect.token_revoked` | warn | `protect()` | `userId`, `mode` |
683
- | `auth.protect.auto_refresh` | info | `protect()` | `userId`, `mode` |
684
- | `auth.authorize.passed` | info | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
685
- | `auth.authorize.denied` | warn | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
686
- | `auth.authorize.unauthenticated` | warn | `authorize()` | `requiredRoles` |
687
- | `auth.permit.passed` | info | `permit()` | `userId` |
688
- | `auth.permit.denied` | warn | `permit()` | `userId` |
689
- | `auth.permit.role_bypass` | info | `permit()` | `userId`, `bypassedByRole` |
690
- | `auth.permit.unauthenticated` | warn | `permit()` | — |
691
- | `auth.register.success` | info | router | `userId`, `duration_ms` |
692
- | `auth.register.failure` | warn | router | `errorCode`, `duration_ms` |
693
- | `auth.login.success` | info | router | `userId`, `duration_ms` |
694
- | `auth.login.failure` | warn | router | `errorCode`, `duration_ms` |
695
- | `auth.refresh.success` | info | router | `userId`, `duration_ms` |
696
- | `auth.refresh.failure` | warn | router | `errorCode`, `duration_ms` |
697
- | `auth.logout` | info | router | `duration_ms` |
698
- | `auth.logout_all` | info | router | `userId`, `duration_ms` |
699
- | `auth.password.changed` | info | router | `userId`, `duration_ms` |
700
- | `auth.password.change_failure` | warn | router | `userId`, `errorCode`, `duration_ms` |
701
- | `auth.roles.assigned` | info | router | `targetUserId`, `roles`, `duration_ms` |
702
- | `auth.roles.assign_failure` | warn | router | `targetUserId`, `errorCode`, `duration_ms` |
703
- | `auth.identifiers.created` | info | router | `userId`, `count`, `duration_ms` |
704
- | `auth.identifiers.updated` | info | router | `userId`, `count`, `duration_ms` |
705
- | `auth.identifiers.deleted` | info | router | `userId`, `duration_ms` |
706
- | `auth.identifiers.primary_changed` | info | router | `userId`, `duration_ms` |
913
+ | Event | Level | Emitted by | Key fields |
914
+ | -------------------------------- | ----- | ------------- | ------------------------------------------ |
915
+ | `auth.protect.success` | info | `protect()` | `userId`, `mode` |
916
+ | `auth.protect.failure` | warn | `protect()` | `errorCode`, `mode` |
917
+ | `auth.protect.token_revoked` | warn | `protect()` | `userId`, `mode` |
918
+ | `auth.protect.auto_refresh` | info | `protect()` | `userId`, `mode` |
919
+ | `auth.authorize.passed` | info | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
920
+ | `auth.authorize.denied` | warn | `authorize()` | `userId`, `userRoles`, `requiredRoles` |
921
+ | `auth.authorize.unauthenticated` | warn | `authorize()` | `requiredRoles` |
922
+ | `auth.permit.passed` | info | `permit()` | `userId` |
923
+ | `auth.permit.denied` | warn | `permit()` | `userId` |
924
+ | `auth.permit.role_bypass` | info | `permit()` | `userId`, `bypassedByRole` |
925
+ | `auth.permit.unauthenticated` | warn | `permit()` | — |
926
+ | `auth.register.success` | info | router | `userId`, `duration_ms` |
927
+ | `auth.register.failure` | warn | router | `errorCode`, `duration_ms` |
928
+ | `auth.login.success` | info | router | `userId`, `duration_ms` |
929
+ | `auth.login.failure` | warn | router | `errorCode`, `duration_ms` |
930
+ | `auth.refresh.success` | info | router | `userId`, `duration_ms` |
931
+ | `auth.refresh.failure` | warn | router | `errorCode`, `duration_ms` |
932
+ | `auth.logout` | info | router | `duration_ms` |
933
+ | `auth.logout_all` | info | router | `userId`, `duration_ms` |
934
+ | `auth.password.changed` | info | router | `userId`, `duration_ms` |
935
+ | `auth.password.change_failure` | warn | router | `userId`, `errorCode`, `duration_ms` |
936
+ | `auth.roles.assigned` | info | router | `targetUserId`, `roles`, `duration_ms` |
937
+ | `auth.roles.assign_failure` | warn | router | `targetUserId`, `errorCode`, `duration_ms` |
938
+ | `auth.identifiers.created` | info | router | `userId`, `count`, `duration_ms` |
939
+ | `auth.identifiers.updated` | info | router | `userId`, `count`, `duration_ms` |
940
+ | `auth.identifiers.deleted` | info | router | `userId`, `duration_ms` |
707
941
 
708
942
  All entries include `service` (configurable via `loggerService`) and `requestId` when available.
709
943
 
710
944
  ### `SentriLogger` interface
711
945
 
712
946
  ```typescript
713
- import type { SentriLogger } from 'sentri';
947
+ import type { SentriLogger } from "sentri";
714
948
 
715
949
  const myLogger: SentriLogger = {
716
- info(data: Record<string, unknown>) { /* ... */ },
717
- warn(data: Record<string, unknown>) { /* ... */ },
718
- error(data: Record<string, unknown>) { /* ... */ },
950
+ info(data: Record<string, unknown>) {
951
+ /* ... */
952
+ },
953
+ warn(data: Record<string, unknown>) {
954
+ /* ... */
955
+ },
956
+ error(data: Record<string, unknown>) {
957
+ /* ... */
958
+ },
719
959
  };
720
960
  ```
721
961
 
@@ -723,26 +963,37 @@ const myLogger: SentriLogger = {
723
963
 
724
964
  ## Migration Guide
725
965
 
966
+ ### 5.0.0 Breaking Changes
967
+
968
+ | What changed | Action required |
969
+ | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
970
+ | `is_primary` column removed from `sentri_identifiers` | Drop and recreate tables — run `auth.migrate()` after |
971
+ | `AuthUser` no longer has `identifier` / `identifierType` | Update any code reading `req.user` — shape is now `{ id, roles }` |
972
+ | JWT payload no longer includes `identifier` / `identifierType` | Any code decoding the token directly must drop these fields |
973
+ | `PATCH /me/identifiers/primary` endpoint removed | No replacement — the concept of primary is gone |
974
+ | `changePrimaryIdentifier()` removed from `ServerAuthClient` | Delete call sites |
975
+ | `ChangePrimaryResult` type removed | Delete references from type imports |
976
+ | `bulkDeleteIdentifiers` guard changed | Old: "cannot delete primary". New: "must keep at least one identifier" |
977
+
726
978
  ### 4.0.0 Breaking Changes
727
979
 
728
- | What changed | Action required |
729
- |---|---|
730
- | `sentri_users` no longer has an `identifier` column | Drop and recreate tables — identities now live in `sentri_identifiers` |
731
- | New `sentri_identifiers` table | Created automatically by `auth.migrate()` |
732
- | `RegisterInput.identifier` → `RegisterInput.identifiers` (array) | Update register calls to pass `identifiers: [{ type, value }]` |
733
- | `AuthUser` now includes `identifierType` | Update any code reading `req.user` to expect this new field |
734
- | `PATCH /me/identifier` removed | Use `PUT /me/identifiers` for updates, `PATCH /me/identifiers/primary` for primary change |
735
- | `changeIdentifier()` removed from `ServerAuthClient` | Use `bulkUpdateIdentifiers()` or `changePrimaryIdentifier()` |
736
- | New error codes: `IDENTIFIER_NOT_FOUND`, `IDENTIFIER_ALREADY_EXISTS` | Handle these in your error handlers as needed |
980
+ | What changed | Action required |
981
+ | -------------------------------------------------------------------- | ---------------------------------------------------------------------- |
982
+ | `sentri_users` no longer has an `identifier` column | Drop and recreate tables — identities now live in `sentri_identifiers` |
983
+ | New `sentri_identifiers` table | Created automatically by `auth.migrate()` |
984
+ | `RegisterInput.identifier` → `RegisterInput.identifiers` (array) | Update register calls to pass `identifiers: [{ type, value }]` |
985
+ | `PATCH /me/identifier` removed | Use `PUT /me/identifiers` for updates |
986
+ | `changeIdentifier()` removed from `ServerAuthClient` | Use `bulkUpdateIdentifiers()` |
987
+ | New error codes: `IDENTIFIER_NOT_FOUND`, `IDENTIFIER_ALREADY_EXISTS` | Handle these in your error handlers as needed |
737
988
 
738
989
  ### 3.0.0 Breaking Changes
739
990
 
740
- | What changed | Action required |
741
- |---|---|
742
- | `createAuth` now requires `mode: 'server'` or `mode: 'client'` | Add `mode: 'server'` to existing configs |
743
- | `adapter` field removed — Sentri owns the schema | Remove adapter, add `dialect` (Kysely Dialect) |
744
- | `algorithm` now supports `'RS256' \| 'RS384' \| 'RS512'` | Optional — HS256 still default |
745
- | `AuthError` renamed to `SentriError` | Update imports: `import { SentriError } from 'sentri'` |
746
- | `AUTH_ERROR_STATUS` renamed to `SENTRI_ERROR_STATUS` | Update references |
747
- | `npx sentri generate` removed | Run `await auth.migrate()` at startup instead |
748
- | Templates directory removed | No longer needed |
991
+ | What changed | Action required |
992
+ | -------------------------------------------------------------- | ------------------------------------------------------ |
993
+ | `createAuth` now requires `mode: 'server'` or `mode: 'client'` | Add `mode: 'server'` to existing configs |
994
+ | `adapter` field removed — Sentri owns the schema | Remove adapter, add `dialect` (Kysely Dialect) |
995
+ | `algorithm` now supports `'RS256' \| 'RS384' \| 'RS512'` | Optional — HS256 still default |
996
+ | `AuthError` renamed to `SentriError` | Update imports: `import { SentriError } from 'sentri'` |
997
+ | `AUTH_ERROR_STATUS` renamed to `SENTRI_ERROR_STATUS` | Update references |
998
+ | `npx sentri generate` removed | Run `await auth.migrate()` at startup instead |
999
+ | Templates directory removed | No longer needed |