sentri 1.0.4 → 1.0.6

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.
Files changed (53) hide show
  1. package/README.md +314 -860
  2. package/dist/cli.js +79 -26
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client.d.ts +34 -69
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +0 -6
  7. package/dist/client.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/libs/config.d.ts +45 -1
  12. package/dist/libs/config.d.ts.map +1 -1
  13. package/dist/libs/config.js +40 -2
  14. package/dist/libs/config.js.map +1 -1
  15. package/dist/libs/hash.d.ts +14 -0
  16. package/dist/libs/hash.d.ts.map +1 -1
  17. package/dist/libs/hash.js +14 -0
  18. package/dist/libs/hash.js.map +1 -1
  19. package/dist/libs/token.d.ts +37 -0
  20. package/dist/libs/token.d.ts.map +1 -1
  21. package/dist/libs/token.js +63 -0
  22. package/dist/libs/token.js.map +1 -1
  23. package/dist/middleware/authorize.d.ts +15 -0
  24. package/dist/middleware/authorize.d.ts.map +1 -1
  25. package/dist/middleware/authorize.js +18 -3
  26. package/dist/middleware/authorize.js.map +1 -1
  27. package/dist/middleware/permit.d.ts +8 -8
  28. package/dist/middleware/permit.d.ts.map +1 -1
  29. package/dist/middleware/permit.js +10 -10
  30. package/dist/middleware/permit.js.map +1 -1
  31. package/dist/middleware/protect.d.ts +17 -0
  32. package/dist/middleware/protect.d.ts.map +1 -1
  33. package/dist/middleware/protect.js +22 -5
  34. package/dist/middleware/protect.js.map +1 -1
  35. package/dist/middleware/router.d.ts +10 -6
  36. package/dist/middleware/router.d.ts.map +1 -1
  37. package/dist/middleware/router.js +122 -124
  38. package/dist/middleware/router.js.map +1 -1
  39. package/dist/services/auth.d.ts +78 -2
  40. package/dist/services/auth.d.ts.map +1 -1
  41. package/dist/services/auth.js +90 -5
  42. package/dist/services/auth.js.map +1 -1
  43. package/dist/types/auth.d.ts +176 -2
  44. package/dist/types/auth.d.ts.map +1 -1
  45. package/dist/types/auth.js +20 -1
  46. package/dist/types/auth.js.map +1 -1
  47. package/package.json +11 -3
  48. package/templates/drizzle/adapter.ts +154 -0
  49. package/templates/drizzle/auth.ts +47 -0
  50. package/templates/drizzle/schema.ts +47 -0
  51. package/templates/prisma/adapter.ts +122 -0
  52. package/templates/prisma/auth.ts +50 -0
  53. /package/templates/{schema.prisma → prisma/schema.prisma} +0 -0
package/README.md CHANGED
@@ -1,1044 +1,498 @@
1
1
  # sentri
2
2
 
3
- Library autentikasi dan otorisasi personal untuk Express + PostgreSQL. ORM-agnostic kamu implementasi adapter database sendiri sesuai ORM yang dipakai.
4
-
5
- ## Fitur
6
-
7
- - **Flexible identifier** — email, username, nomor HP, atau string unik apapun
8
- - **Access token + refresh token** dari satu secret; dirotasi otomatis setiap refresh
9
- - **Session-based revocation** — logout instan tanpa token blacklist
10
- - **Multi-role** dengan type safety compile-time via TypeScript generics
11
- - **Tiga lapis middleware**: `protect` (autentikasi) → `authorize` (role) → `permit` (kepemilikan resource)
12
- - **Built-in router** — 6 endpoint auth siap pakai tanpa boilerplate
13
- - **Cookie mode** — refresh token otomatis disimpan di httpOnly cookie, tanpa `cookie-parser`
14
- - **ORM-agnostic** — satu interface, bisa diimplementasi dengan Prisma, Drizzle, raw SQL, apapun
3
+ Auth and authorization library for Express + PostgreSQL. Provides JWT-based authentication with refresh token rotation, role-based access control, fine-grained permission checks, and a pre-built Express router — all behind a single typed client.
15
4
 
16
5
  ---
17
6
 
18
- ## Daftar Isi
19
-
20
- 1. [Instalasi](#instalasi)
21
- 2. [Cara Kerja](#cara-kerja)
22
- 3. [Setup Lengkap dari Nol](#setup-lengkap-dari-nol)
23
- 4. [Contoh HTTP Request & Response](#contoh-http-request--response)
24
- 5. [Middleware](#middleware)
25
- 6. [Custom Routes](#custom-routes)
26
- 7. [Cookie Mode](#cookie-mode)
27
- 8. [Identifier Fleksibel](#identifier-fleksibel)
28
- 9. [Konfigurasi](#konfigurasi)
29
- 10. [AuthAdapter Interface](#authadapter-interface)
30
- 11. [Error Handling](#error-handling)
31
- 12. [Type Exports](#type-exports)
32
- 13. [Keamanan](#keamanan)
7
+ ## Table of Contents
8
+
9
+ - [Installation](#installation)
10
+ - [Quick Start](#quick-start)
11
+ - [CLI](#cli)
12
+ - [Configuration](#configuration)
13
+ - [Custom Route Handlers](#custom-route-handlers)
14
+ - [Adapter Interface](#adapter-interface)
15
+ - [Pre-built Router](#pre-built-router)
16
+ - [Middleware](#middleware)
17
+ - [Programmatic API](#programmatic-api)
18
+ - [Types](#types)
19
+ - [Error Handling](#error-handling)
33
20
 
34
21
  ---
35
22
 
36
- ## Instalasi
23
+ ## Installation
37
24
 
38
25
  ```bash
39
- npm install sentri express
40
- npm install -D @types/express typescript
26
+ npm install sentri
41
27
  ```
42
28
 
29
+ **Peer dependency:** `express >= 4.0.0`
30
+
43
31
  ---
44
32
 
45
- ## Cara Kerja
33
+ ## Quick Start
46
34
 
47
- Sebelum mulai, penting memahami dua konsep utama:
35
+ ### 1. Generate templates
48
36
 
49
- ### Dua token, satu secret
37
+ ```bash
38
+ # Prisma
39
+ npx sentri generate prisma
50
40
 
41
+ # Drizzle
42
+ npx sentri generate drizzle
51
43
  ```
52
- secret ──→ secret:access → sign access token (pendek, 15 menit)
53
- └──→ secret:refresh → sign refresh token (panjang, 7 hari)
54
- ```
55
-
56
- Kamu hanya perlu simpan satu `secret`. Library otomatis membuat dua derived key terpisah.
57
44
 
58
- ### Access token vs Refresh token
45
+ This creates:
59
46
 
60
- | | Access Token | Refresh Token |
61
- |---|---|---|
62
- | **Isi JWT** | `{ id, identifier, roles }` | `{ sessionId }` |
63
- | **Default expire** | 15 menit | 7 hari |
64
- | **Dipakai untuk** | Akses endpoint protected | Minta access token baru |
65
- | **Disimpan di** | Memory / header | Storage yang lebih aman |
47
+ ```
48
+ src/lib/
49
+ index.ts ← barrel export
50
+ sentri/
51
+ adapter.ts ← AuthAdapter implementation
52
+ auth.ts ← configured auth client
53
+ schema.ts ← table definitions (Drizzle only)
54
+ prisma/
55
+ schema.prisma ← Prisma models (Prisma only, created or appended)
56
+ ```
66
57
 
67
- ### Session-based revocation
58
+ ### 2. Mount the router
68
59
 
69
- Refresh token tidak menyimpan data user — hanya `sessionId`. Saat dipakai, library cek session tersebut masih ada di DB. Jika sudah dihapus (logout), token otomatis tidak berlaku tanpa perlu blacklist.
60
+ ```typescript
61
+ import express from 'express';
62
+ import { auth } from './lib/sentri/auth.js';
70
63
 
64
+ const app = express();
65
+ app.use(express.json());
66
+ app.use('/auth', auth.router());
71
67
  ```
72
- Login → buat session di DB → refresh token berisi sessionId
73
- Logout → hapus session di DB → refresh token langsung tidak berlaku
74
- ```
68
+
69
+ Done. All endpoints are available at `/auth/*`.
75
70
 
76
71
  ---
77
72
 
78
- ## Setup Lengkap dari Nol
73
+ ## CLI
79
74
 
80
- ### Langkah 1: Struktur project
75
+ ### `sentri generate <prisma|drizzle>`
81
76
 
82
- ```
83
- src/
84
- ├── index.ts ← Express app utama
85
- ├── lib/
86
- │ ├── auth.ts ← createAuth() dipanggil di sini
87
- │ ├── adapter.ts ← implementasi AuthAdapter
88
- │ └── prisma.ts ← inisialisasi PrismaClient
89
- └── routes/
90
- └── user.ts ← contoh protected routes
91
- ```
92
-
93
- ### Langkah 2: Siapkan environment
77
+ Generates adapter, auth config, and schema templates in one command.
94
78
 
95
79
  ```bash
96
- # .env
97
- DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
98
- JWT_SECRET="ganti-dengan-string-acak-minimal-32-karakter-ini"
99
- PORT=3000
80
+ npx sentri generate prisma
81
+ npx sentri generate drizzle
100
82
  ```
101
83
 
102
- > **Penting:** `JWT_SECRET` harus minimal 32 karakter. `createAuth()` akan throw `CONFIGURATION_ERROR` jika kurang dari itu.
84
+ **What gets created:**
103
85
 
104
- ### Langkah 3: Siapkan tabel database
86
+ | File | Behavior |
87
+ |---|---|
88
+ | `src/lib/sentri/adapter.ts` | Created fresh (error if exists) |
89
+ | `src/lib/sentri/auth.ts` | Created fresh (error if exists) |
90
+ | `src/lib/sentri/schema.ts` | Drizzle only — created fresh or tables appended |
91
+ | `prisma/schema.prisma` | Prisma only — created fresh or models appended |
92
+ | `src/lib/index.ts` | Created fresh, skipped if already exists |
105
93
 
106
- Library membutuhkan empat tabel: **User**, **Role**, **UserRole**, dan **Session**.
94
+ ---
107
95
 
108
- **Contoh Prisma schema** (`prisma/schema.prisma`):
96
+ ## Configuration
109
97
 
110
- ```prisma
111
- generator client {
112
- provider = "prisma-client-js"
113
- }
98
+ ```typescript
99
+ import { createAuth } from 'sentri';
114
100
 
115
- datasource db {
116
- provider = "postgresql"
117
- url = env("DATABASE_URL")
118
- }
101
+ export const auth = createAuth({
102
+ secret: process.env.JWT_SECRET!, // required — keep in env
103
+ validRoles: ['user', 'admin'] as const, // required — use `as const` for type safety
104
+ adapter: myAdapter, // required — see Adapter Interface
105
+
106
+ // optional
107
+ accessExpiresIn: '15m', // default: '15m'
108
+ refreshExpiresIn: '7d', // default: '7d'
109
+ algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
110
+ saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
111
+
112
+ cookie: { // optional — enables httpOnly cookie for refresh token
113
+ secure: process.env.NODE_ENV === 'production',
114
+ // name: 'refresh_token', // default: 'refresh_token'
115
+ // httpOnly: true, // default: true
116
+ // sameSite: 'strict', // default: 'strict'
117
+ // path: '/', // default: '/'
118
+ },
119
119
 
120
- model User {
121
- id String @id @default(cuid())
122
- email String @unique
123
- passwordHash String
124
- createdAt DateTime @default(now())
125
- updatedAt DateTime @updatedAt
126
- userRoles UserRole[]
127
- sessions Session[]
128
- }
120
+ // router: { // optional — replace built-in service logic per route
121
+ // login: async (input) => { ... },
122
+ // signup: async (input) => { ... },
123
+ // refresh: async (refreshToken) => { ... },
124
+ // logout: async (refreshToken) => { ... },
125
+ // logoutAll: async (userId) => { ... },
126
+ // assignRoles: async (userId, roles) => { ... },
127
+ // },
128
+ });
129
+ ```
129
130
 
130
- model Role {
131
- id String @id @default(cuid())
132
- name String @unique
133
- userRoles UserRole[]
134
- }
131
+ `accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'15m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
135
132
 
136
- model UserRole {
137
- userId String
138
- roleId String
139
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
140
- role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
133
+ When `cookie` is configured, the refresh token is stored in an httpOnly cookie automatically. No `cookie-parser` middleware is needed.
141
134
 
142
- @@id([userId, roleId])
143
- }
135
+ ---
144
136
 
145
- model Session {
146
- id String @id @default(cuid())
147
- userId String
148
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
149
- expiresAt DateTime
150
- createdAt DateTime @default(now())
151
- }
152
- ```
137
+ ## Custom Route Handlers
153
138
 
154
- ```bash
155
- npx prisma db push # atau prisma migrate dev
156
- npx prisma generate
157
- ```
139
+ The `router` field in config lets you replace the built-in service logic for individual routes while the router still handles request parsing, input validation, and response formatting.
158
140
 
159
- ### Langkah 4: Buat adapter
160
-
161
- Adapter adalah jembatan antara library dan database kamu. Library mendefinisikan interface-nya, kamu mengisi implementasinya.
141
+ Each key is optional — only override what you need. Any key you omit falls back to the built-in behaviour.
162
142
 
163
143
  ```typescript
164
- // src/lib/adapter.ts
165
- import type { AuthAdapter } from 'sentri';
166
- import { prisma } from './prisma.js';
167
-
168
- // Helper: ubah format row DB ke format yang diharapkan library
169
- function toUserRecord(raw: {
170
- id: string;
171
- email: string;
172
- passwordHash: string;
173
- userRoles: { role: { name: string } }[];
174
- }) {
175
- return {
176
- id: raw.id,
177
- identifier: raw.email, // kolom "email" di DB → "identifier" di library
178
- passwordHash: raw.passwordHash,
179
- roles: raw.userRoles.map((ur) => ur.role.name),
180
- };
181
- }
144
+ import { createAuth, AuthError } from 'sentri';
145
+ import type { AuthResult } from 'sentri';
182
146
 
183
- export const adapter: AuthAdapter = {
184
- user: {
185
- // Dipanggil saat login — cari user berdasarkan identifier
186
- async findByIdentifier(identifier) {
187
- const raw = await prisma.user.findUnique({
188
- where: { email: identifier },
189
- include: { userRoles: { include: { role: true } } },
190
- });
191
- return raw ? toUserRecord(raw) : null;
192
- },
193
-
194
- // Dipanggil saat validasi token
195
- async findById(id) {
196
- const raw = await prisma.user.findUnique({
197
- where: { id },
198
- include: { userRoles: { include: { role: true } } },
199
- });
200
- return raw ? toUserRecord(raw) : null;
201
- },
202
-
203
- // Dipanggil saat signup
204
- async create({ identifier, passwordHash, roles }) {
205
- const user = await prisma.user.create({ data: { email: identifier, passwordHash } });
206
-
207
- if (roles.length > 0) {
208
- // Upsert role agar tidak perlu seed manual
209
- const roleRecords = await Promise.all(
210
- roles.map((name) =>
211
- prisma.role.upsert({ where: { name }, create: { name }, update: {} }),
212
- ),
213
- );
214
- await prisma.userRole.createMany({
215
- data: roleRecords.map((r) => ({ userId: user.id, roleId: r.id })),
216
- });
147
+ export const auth = createAuth({
148
+ secret: process.env.JWT_SECRET!,
149
+ validRoles: ['user', 'admin'] as const,
150
+ adapter: myAdapter,
151
+
152
+ router: {
153
+ // Add an OTP check before issuing tokens
154
+ login: async (input): Promise<AuthResult> => {
155
+ const otpVerified = await redis.get(`otp:${input.identifier}`);
156
+ if (!otpVerified) {
157
+ return { success: false, error: new AuthError('INVALID_CREDENTIALS', 'OTP required') };
217
158
  }
218
-
219
- return { id: user.id };
220
- },
221
- },
222
-
223
- session: {
224
- // Dipanggil setelah login/signup berhasil
225
- async create({ userId, expiresAt }) {
226
- const session = await prisma.session.create({ data: { userId, expiresAt } });
227
- return { id: session.id };
159
+ return defaultLogin(input);
228
160
  },
229
161
 
230
- // Dipanggil saat refresh token ambil session + data user
231
- async findById(sessionId) {
232
- const raw = await prisma.session.findUnique({
233
- where: { id: sessionId },
234
- include: { user: { include: { userRoles: { include: { role: true } } } } },
235
- });
236
- if (!raw) return null;
237
- return {
238
- id: raw.id,
239
- userId: raw.userId,
240
- expiresAt: raw.expiresAt,
241
- createdAt: raw.createdAt,
242
- user: toUserRecord(raw.user),
243
- };
244
- },
245
-
246
- // Dipanggil saat logout atau rotasi token
247
- async delete(sessionId) {
248
- await prisma.session.delete({ where: { id: sessionId } });
162
+ // Send a welcome email after signup
163
+ signup: async (input) => {
164
+ const result = await defaultSignup(input);
165
+ if (result.success) {
166
+ await emailService.sendWelcome(input.identifier);
167
+ }
168
+ return result;
249
169
  },
250
170
 
251
- // Dipanggil saat logout-all
252
- async deleteAllForUser(userId) {
253
- await prisma.session.deleteMany({ where: { userId } });
171
+ // Audit-log every token rotation
172
+ refresh: async (refreshToken) => {
173
+ const result = await defaultRefresh(refreshToken);
174
+ if (result.success) {
175
+ await auditLog.record('token_rotated', result.user.id);
176
+ }
177
+ return result;
254
178
  },
255
179
  },
256
- };
257
- ```
258
-
259
- ### Langkah 5: Buat auth client
260
-
261
- ```typescript
262
- // src/lib/auth.ts
263
- import { createAuth } from 'sentri';
264
- import { adapter } from './adapter.js';
265
-
266
- // Definisikan role yang ada di aplikasimu
267
- export type AppRole = 'user' | 'admin' | 'moderator';
268
-
269
- export const auth = createAuth<AppRole>({
270
- secret: process.env.JWT_SECRET!,
271
- validRoles: ['user', 'admin', 'moderator'],
272
- adapter,
273
- // Semua di bawah ini opsional, nilai berikut adalah default:
274
- // accessExpiresIn: '15m',
275
- // refreshExpiresIn: '7d',
276
- // saltRounds: 12,
277
- // algorithm: 'HS256',
278
180
  });
279
181
  ```
280
182
 
281
- Setelah ini, TypeScript tahu persis role apa yang valid. `auth.authorize('superuser')` adalah **compile error**.
183
+ ### Available handler signatures
282
184
 
283
- ### Langkah 6: Pasang di Express
185
+ | Key | Signature | Must return |
186
+ |---|---|---|
187
+ | `signup` | `(input: SignupInput) => Promise<SignupResult>` | `SignupResult` |
188
+ | `login` | `(input: LoginInput) => Promise<AuthResult>` | `AuthResult` |
189
+ | `refresh` | `(refreshToken: string) => Promise<RefreshResult>` | `RefreshResult` |
190
+ | `logout` | `(refreshToken: string \| undefined) => Promise<void>` | `void` |
191
+ | `logoutAll` | `(userId: string) => Promise<void>` | `void` |
192
+ | `assignRoles` | `(userId: string, roles: string[]) => Promise<AssignRolesResult>` | `AssignRolesResult` |
284
193
 
285
- ```typescript
286
- // src/index.ts
287
- import 'dotenv/config';
288
- import express from 'express';
289
- import { AuthError } from 'sentri';
290
- import { auth } from './lib/auth.js';
291
- import { userRouter } from './routes/user.js';
292
- import { prisma } from './lib/prisma.js';
194
+ The router always validates the request body and URL parameters before calling any handler. Your function receives the already-validated, trimmed input.
293
195
 
294
- const app = express();
295
-
296
- // express.json() WAJIB dipasang sebelum auth.router()
297
- app.use(express.json());
196
+ ---
298
197
 
299
- // 6 endpoint auth langsung jadi — tidak perlu tulis manual
300
- app.use('/auth', auth.router());
198
+ ## Adapter Interface
301
199
 
302
- // Route lain milikmu
303
- app.use('/user', userRouter);
304
-
305
- // Error handler global — tangkap AuthError dari semua middleware
306
- app.use((
307
- err: unknown,
308
- _req: express.Request,
309
- res: express.Response,
310
- _next: express.NextFunction,
311
- ) => {
312
- if (err instanceof AuthError) {
313
- const status =
314
- err.code === 'UNAUTHORIZED' ? 401 :
315
- err.code === 'FORBIDDEN' ? 403 : 400;
316
- res.status(status).json({ code: err.code, message: err.message });
317
- return;
318
- }
319
- console.error(err);
320
- res.status(500).json({ message: 'Internal server error' });
321
- });
200
+ The adapter connects sentri to your database. Implement `AuthAdapter` for any ORM or data layer.
322
201
 
323
- const port = Number(process.env.PORT ?? 3000);
324
- app.listen(port, () => console.log(`Server running on http://localhost:${port}`));
202
+ ```typescript
203
+ import type { AuthAdapter } from 'sentri';
325
204
 
326
- process.on('SIGINT', async () => {
327
- await prisma.$disconnect();
328
- process.exit(0);
329
- });
205
+ const adapter: AuthAdapter = {
206
+ user: {
207
+ findByIdentifier(identifier: string): Promise<UserRecord | null>,
208
+ findById(id: string): Promise<UserRecord | null>,
209
+ create(data: CreateUserData): Promise<{ id: string }>,
210
+ updateRoles(userId: string, roles: string[]): Promise<void>,
211
+ },
212
+ session: {
213
+ create(data: { userId: string; expiresAt: Date }): Promise<{ id: string }>,
214
+ findById(sessionId: string): Promise<(SessionRecord & { user: UserRecord }) | null>,
215
+ delete(sessionId: string): Promise<void>,
216
+ deleteAllForUser(userId: string): Promise<void>,
217
+ },
218
+ };
330
219
  ```
331
220
 
332
- ### Langkah 7: Buat protected routes
221
+ `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.
333
222
 
334
- ```typescript
335
- // src/routes/user.ts
336
- import { Router } from 'express';
337
- import { auth } from '../lib/auth.js';
223
+ ### Using the generated adapter
338
224
 
339
- export const userRouter = Router();
225
+ The generated `adapter.ts` exports `createAdapter(db)` so you can pass your existing database instance:
340
226
 
341
- // Hanya user yang sudah login
342
- userRouter.get('/me', auth.protect(), (req, res) => {
343
- res.json(req.user); // { id, identifier, roles }
344
- });
227
+ ```typescript
228
+ // Prisma
229
+ import { PrismaClient } from '@prisma/client';
230
+ import { createAdapter } from './adapter.js';
345
231
 
346
- // Hanya admin
347
- userRouter.get('/admin-only',
348
- auth.protect(),
349
- auth.authorize('admin'),
350
- (req, res) => res.json({ message: 'selamat datang admin' }),
351
- );
232
+ const prisma = new PrismaClient();
233
+ export const adapter = createAdapter(prisma);
352
234
 
353
- // Admin atau moderator
354
- userRouter.get('/dashboard',
355
- auth.protect(),
356
- auth.authorize('admin', 'moderator'),
357
- (req, res) => res.json({ message: 'selamat datang di dashboard' }),
358
- );
235
+ // Drizzle
236
+ import { db } from '../db.js';
237
+ import { createAdapter } from './adapter.js';
359
238
 
360
- // User hanya bisa edit profilnya sendiri; admin bisa edit siapapun
361
- userRouter.put('/:userId',
362
- auth.protect(),
363
- auth.permit({
364
- roles: ['admin'],
365
- check: (req) => req.user!.id === req.params['userId'],
366
- }),
367
- (req, res) => res.json({ message: `profil ${req.params['userId']} diperbarui` }),
368
- );
239
+ export const adapter = createAdapter(db);
369
240
  ```
370
241
 
371
- ---
372
-
373
- ## Contoh HTTP Request & Response
242
+ `createAdapter` throws `AuthError` with code `CONFIGURATION_ERROR` at runtime if called without a `db` argument.
374
243
 
375
- Gunakan `curl`, Postman, atau HTTP client lain. Semua request body harus JSON.
244
+ ---
376
245
 
377
- ### Signup
246
+ ## Pre-built Router
378
247
 
379
- ```bash
380
- curl -X POST http://localhost:3000/auth/signup \
381
- -H "Content-Type: application/json" \
382
- -d '{ "identifier": "alice@example.com", "password": "password123", "roles": ["user"] }'
383
- ```
248
+ `auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
384
249
 
385
- ```json
386
- // 201 Created
250
+ ```typescript
387
251
  {
388
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
389
- "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
390
- "user": {
391
- "id": "clx1234abcd",
392
- "identifier": "alice@example.com",
393
- "roles": ["user"]
394
- }
252
+ error: boolean,
253
+ statusCode: number,
254
+ message: string,
255
+ data: T | null
395
256
  }
396
257
  ```
397
258
 
398
- ```json
399
- // 409 Conflict — identifier sudah terdaftar
400
- { "code": "USER_ALREADY_EXISTS", "message": "User already exists" }
401
- ```
259
+ ### Endpoints
402
260
 
403
- ```json
404
- // 400 Bad Request — role tidak ada di validRoles
405
- { "code": "INVALID_ROLE", "message": "Invalid roles: superuser" }
406
- ```
261
+ #### `POST /signup`
407
262
 
408
- ### Login
263
+ Register a new user. Does **not** issue tokens — call `/login` after signup.
409
264
 
410
- ```bash
411
- curl -X POST http://localhost:3000/auth/login \
412
- -H "Content-Type: application/json" \
413
- -d '{ "identifier": "alice@example.com", "password": "password123" }'
414
265
  ```
415
-
416
- ```json
417
- // 200 OK
418
- {
419
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
420
- "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
421
- "user": {
422
- "id": "clx1234abcd",
423
- "identifier": "alice@example.com",
424
- "roles": ["user"]
425
- }
426
- }
266
+ Body: { identifier, password, roles?: string[] }
267
+ Returns: { user: { id, identifier, roles } }
268
+ Status: 201
427
269
  ```
428
270
 
429
- ```json
430
- // 401 Unauthorized — identifier atau password salah
431
- { "code": "INVALID_CREDENTIALS", "message": "Invalid credentials" }
432
- ```
271
+ `password` must be 8–72 characters. `roles` must be a subset of `validRoles`.
433
272
 
434
- ### Akses endpoint protected
273
+ ---
435
274
 
436
- Kirim `accessToken` di header `Authorization`:
275
+ #### `POST /login`
437
276
 
438
- ```bash
439
- curl http://localhost:3000/user/me \
440
- -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
441
- ```
277
+ Authenticate a user and start a session.
442
278
 
443
- ```json
444
- // 200 OK
445
- { "id": "clx1234abcd", "identifier": "alice@example.com", "roles": ["user"] }
446
279
  ```
447
-
448
- ```json
449
- // 401 Unauthorized — token tidak ada atau tidak valid
450
- { "code": "UNAUTHORIZED", "message": "Missing or malformed Authorization header" }
280
+ Body: { identifier, password }
281
+ Returns: { accessToken, user: { id, identifier, roles } }
282
+ Status: 200
451
283
  ```
452
284
 
453
- ```json
454
- // 401 Unauthorized — token sudah expire
455
- { "code": "TOKEN_EXPIRED", "message": "Token has expired" }
456
- ```
285
+ The refresh token is stored in an httpOnly cookie. The access token is returned in the response body.
457
286
 
458
- ```json
459
- // 403 Forbidden — tidak punya role yang dibutuhkan
460
- { "code": "FORBIDDEN", "message": "Requires one of roles: admin" }
461
- ```
287
+ ---
462
288
 
463
- ### Refresh token
289
+ #### `POST /refresh`
464
290
 
465
- Access token expire setelah 15 menit. Gunakan refresh token untuk minta yang baru **tanpa login ulang**. Setelah refresh, refresh token lama **tidak berlaku lagi** (rotasi).
291
+ Exchange the refresh token cookie for a new access token. Implements session rotation the old session is deleted and a new one is created.
466
292
 
467
- ```bash
468
- curl -X POST http://localhost:3000/auth/refresh \
469
- -H "Content-Type: application/json" \
470
- -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'
471
293
  ```
472
-
473
- ```json
474
- // 200 OK — simpan kedua token baru, buang yang lama
475
- {
476
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
477
- "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
478
- }
294
+ Cookie: refresh_token=<token> (set automatically by /login)
295
+ Returns: { accessToken }
296
+ Status: 200
479
297
  ```
480
298
 
481
- ```json
482
- // 401 Unauthorized — refresh token sudah dipakai / session di-logout
483
- { "code": "UNAUTHORIZED", "message": "Session not found or revoked" }
484
- ```
299
+ The new refresh token is written back to the cookie. No body required.
485
300
 
486
- ### Logout
301
+ ---
487
302
 
488
- Hapus session saat ini. Refresh token yang terkait langsung tidak berlaku.
303
+ #### `POST /logout`
489
304
 
490
- ```bash
491
- curl -X POST http://localhost:3000/auth/logout \
492
- -H "Content-Type: application/json" \
493
- -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'
494
- ```
305
+ Invalidate the current session.
495
306
 
496
- ```json
497
- // 200 OK
498
- { "message": "logged out" }
307
+ ```
308
+ Cookie: refresh_token=<token>
309
+ Returns: null
310
+ Status: 200
499
311
  ```
500
312
 
501
- ### Logout semua device
313
+ Safe to call even if the cookie is missing or the token is already expired.
502
314
 
503
- Perlu access token yang valid. Menghapus semua session user — efektif logout dari semua device sekaligus.
315
+ ---
504
316
 
505
- ```bash
506
- curl -X POST http://localhost:3000/auth/logout-all \
507
- -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
508
- ```
317
+ #### `POST /logout-all`
509
318
 
510
- ```json
511
- // 200 OK
512
- { "message": "all sessions revoked" }
319
+ Invalidate all sessions for the authenticated user (logout from every device).
320
+
321
+ ```
322
+ Headers: Authorization: Bearer <accessToken>
323
+ Returns: null
324
+ Status: 200
513
325
  ```
514
326
 
515
- ### Cek user saat ini
327
+ ---
516
328
 
517
- Mengembalikan data user yang sedang login berdasarkan access token. Endpoint ini membutuhkan access token yang valid.
329
+ #### `GET /me`
518
330
 
519
- ```bash
520
- curl http://localhost:3000/auth/me \
521
- -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
522
- ```
331
+ Return the currently authenticated user.
523
332
 
524
- ```json
525
- // 200 OK
526
- {
527
- "id": "clx1234abcd",
528
- "identifier": "alice@example.com",
529
- "roles": ["user"]
530
- }
531
333
  ```
532
-
533
- ```json
534
- // 401 Unauthorized — token tidak ada atau tidak valid
535
- { "code": "UNAUTHORIZED", "message": "Missing or malformed Authorization header" }
334
+ Headers: Authorization: Bearer <accessToken>
335
+ Returns: { id, identifier, roles }
336
+ Status: 200
536
337
  ```
537
338
 
538
339
  ---
539
340
 
540
- ## Middleware
341
+ #### `POST /users/:userId/roles`
541
342
 
542
- Library menyediakan tiga middleware yang bisa dikombinasikan:
343
+ Add roles to another user. Restricted to users with the `admin` role. Merges the given roles with the user's existing roles — no duplicates.
543
344
 
544
345
  ```
545
- protect() → autentikasi: siapa yang request?
546
- authorize() → otorisasi role: role apa yang boleh?
547
- permit() → otorisasi resource: boleh akses data ini?
346
+ Headers: Authorization: Bearer <accessToken> (must have admin role)
347
+ Body: { roles: string[] }
348
+ Returns: { user: { id, identifier, roles } }
349
+ Status: 200
548
350
  ```
549
351
 
550
- Ketiganya bekerja seperti rantai. `authorize` dan `permit` selalu dipasang **setelah** `protect`.
352
+ ---
353
+
354
+ ## Middleware
551
355
 
552
356
  ### `auth.protect()`
553
357
 
554
- Verifikasi access token dari header `Authorization: Bearer <token>`. Jika valid, `req.user` tersedia di handler berikutnya.
358
+ Verifies the `Authorization: Bearer <token>` header and injects `request.user` into the request.
555
359
 
556
360
  ```typescript
557
- router.get('/profile', auth.protect(), (req, res) => {
558
- // req.user dijamin ada dan bertipe AuthUser<AppRole>
559
- res.json({
560
- id: req.user!.id,
561
- identifier: req.user!.identifier,
562
- roles: req.user!.roles,
563
- });
361
+ router.get('/dashboard', auth.protect(), (request, response) => {
362
+ response.json(request.user); // { id, identifier, roles }
564
363
  });
565
364
  ```
566
365
 
366
+ ---
367
+
567
368
  ### `auth.authorize(...roles)`
568
369
 
569
- Cek apakah user punya minimal satu dari role yang disebutkan.
370
+ Enforces role-based access. Must be used **after** `auth.protect()`. Passes if the user has at least one of the specified roles.
570
371
 
571
372
  ```typescript
572
- // Satu role
573
- router.post('/posts', auth.protect(), auth.authorize('admin'), createPost);
574
-
575
- // Beberapa role — user cukup punya salah satu
576
- router.put('/posts/:id', auth.protect(), auth.authorize('admin', 'moderator'), updatePost);
373
+ router.delete(
374
+ '/posts/:id',
375
+ auth.protect(),
376
+ auth.authorize('admin'),
377
+ handler,
378
+ );
577
379
  ```
578
380
 
579
- ### `auth.permit(check | options)`
381
+ ---
580
382
 
581
- Cek kepemilikan resource atau kondisi apapun yang butuh data dari request. Cocok untuk aturan seperti "hanya pemilik yang bisa edit".
383
+ ### `auth.permit(check | options)`
582
384
 
583
- **Bentuk 1 fungsi check saja:**
385
+ Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
584
386
 
585
387
  ```typescript
586
- // User hanya bisa akses data miliknya sendiri
587
- router.get('/orders/:orderId',
388
+ // Simple ownership check
389
+ router.put(
390
+ '/users/:id',
588
391
  auth.protect(),
589
- auth.permit(async (req) => {
590
- const order = await db.order.findUnique({ where: { id: req.params['orderId'] } });
591
- return order?.userId === req.user!.id;
592
- }),
593
- getOrderHandler,
392
+ auth.permit((request) => request.user!.id === request.params['id']),
393
+ handler,
594
394
  );
595
- ```
596
395
 
597
- **Bentuk 2 dengan role bypass:**
598
-
599
- ```typescript
600
- // Admin bisa akses semua; user biasa hanya miliknya
601
- router.delete('/orders/:orderId',
396
+ // Admins bypass the check; others must own the resource
397
+ router.delete(
398
+ '/posts/:id',
602
399
  auth.protect(),
603
400
  auth.permit({
604
- roles: ['admin'], // admin selalu lolos, skip check
605
- check: async (req) => {
606
- const order = await db.order.findUnique({ where: { id: req.params['orderId'] } });
607
- return order?.userId === req.user!.id;
608
- },
609
- }),
610
- deleteOrderHandler,
611
- );
612
- ```
613
-
614
- **Contoh kombinasi lengkap:**
615
-
616
- ```typescript
617
- // Hanya user dengan role 'editor' atau 'admin' yang bisa akses,
618
- // dan hanya jika artikel milik mereka (kecuali admin)
619
- router.put('/articles/:id',
620
- auth.protect(), // 1. harus login
621
- auth.authorize('editor', 'admin'), // 2. harus punya role
622
- auth.permit({ // 3. admin bebas, editor harus pemilik
623
401
  roles: ['admin'],
624
- check: async (req) => {
625
- const article = await db.article.findUnique({ where: { id: req.params['id'] } });
626
- return article?.authorId === req.user!.id;
402
+ check: async (request) => {
403
+ const post = await db.post.findUnique({ where: { id: request.params['id'] } });
404
+ return post?.authorId === request.user!.id;
627
405
  },
628
406
  }),
629
- updateArticleHandler,
407
+ handler,
630
408
  );
631
409
  ```
632
410
 
633
411
  ---
634
412
 
635
- ## Custom Routes
413
+ ## Programmatic API
636
414
 
637
- Jika `auth.router()` tidak sesuai kebutuhan, kamu bisa tulis route sendiri menggunakan method `auth.signup()`, `auth.login()`, dll secara langsung.
415
+ Token and password utilities are available on the auth client for use outside the built-in router.
638
416
 
639
417
  ```typescript
640
- import { Router } from 'express';
641
- import { auth } from '../lib/auth.js';
642
-
643
- const router = Router();
644
-
645
- // Contoh: signup dengan response yang dikustomisasi
646
- router.post('/register', async (req, res, next) => {
647
- try {
648
- const result = await auth.signup({
649
- identifier: req.body.email,
650
- password: req.body.password,
651
- roles: ['user'], // selalu assign role 'user' saat register
652
- });
653
-
654
- if (!result.success) {
655
- // Kamu bebas tentukan status code dan format response
656
- const status = result.error.code === 'USER_ALREADY_EXISTS' ? 409 : 400;
657
- return res.status(status).json({ error: result.error.code });
658
- }
659
-
660
- // Contoh: kirim token lewat cookie, bukan body
661
- res.cookie('refreshToken', result.refreshToken, {
662
- httpOnly: true,
663
- secure: process.env.NODE_ENV === 'production',
664
- maxAge: 7 * 24 * 60 * 60 * 1000, // 7 hari
665
- });
666
-
667
- res.status(201).json({
668
- accessToken: result.accessToken,
669
- user: result.user,
670
- });
671
- } catch (err) {
672
- next(err);
673
- }
674
- });
418
+ // Token utilities
419
+ const accessToken = auth.signAccessToken({ id, identifier, roles });
420
+ const user = auth.verifyAccessToken(accessToken); // throws AuthError if invalid
421
+ const { sessionId } = auth.verifyRefreshToken(token); // throws AuthError if invalid
422
+ const refreshToken = auth.signRefreshToken(sessionId);
675
423
 
676
- // Contoh: refresh token dari cookie (bukan body)
677
- router.post('/token', async (req, res, next) => {
678
- try {
679
- const refreshToken = req.cookies['refreshToken'] as string | undefined;
680
- if (!refreshToken) return res.status(401).json({ error: 'UNAUTHORIZED' });
681
-
682
- const result = await auth.refresh(refreshToken);
683
- if (!result.success) return res.status(401).json({ error: result.error.code });
684
-
685
- res.cookie('refreshToken', result.refreshToken, { httpOnly: true, secure: true });
686
- res.json({ accessToken: result.accessToken });
687
- } catch (err) {
688
- next(err);
689
- }
690
- });
691
- ```
692
-
693
- ---
694
-
695
- ## Cookie Mode
696
-
697
- Secara default, built-in router mengembalikan refresh token di response body. Jika kamu ingin menyimpannya di **httpOnly cookie** (lebih aman karena tidak bisa diakses JavaScript), cukup tambah field `cookie` di config:
698
-
699
- ```typescript
700
- // src/lib/auth.ts
701
- export const auth = createAuth<AppRole>({
702
- secret: process.env.JWT_SECRET!,
703
- validRoles: ['user', 'admin', 'moderator'],
704
- adapter,
705
- cookie: {
706
- secure: process.env.NODE_ENV === 'production', // true di production (HTTPS only)
707
- },
708
- });
424
+ // Password utilities
425
+ const hash = await auth.hashPassword('secret123');
426
+ const valid = await auth.verifyPassword('secret123', hash);
709
427
  ```
710
428
 
711
- Tidak ada `cookie-parser` yang diperlukan library membaca header `Cookie` secara langsung.
712
-
713
- ### Perubahan behavior di cookie mode
714
-
715
- | Endpoint | Default (body) | Cookie mode |
716
- |---|---|---|
717
- | `POST /signup` | Response berisi `refreshToken` | Cookie `refresh_token` di-set, `refreshToken` tidak ada di response |
718
- | `POST /login` | Response berisi `refreshToken` | Cookie `refresh_token` di-set, `refreshToken` tidak ada di response |
719
- | `POST /refresh` | Body harus berisi `refreshToken` | Cookie dibaca otomatis, cookie baru di-set |
720
- | `POST /logout` | Body harus berisi `refreshToken` | Cookie dibaca otomatis, cookie dihapus |
721
- | `POST /logout-all` | Cookie dihapus (jika ada) | Cookie dihapus |
722
- | `GET /me` | Tidak terpengaruh cookie mode | Tidak terpengaruh cookie mode |
723
-
724
- ### Contoh curl dengan cookie
725
-
726
- Browser mengelola cookie otomatis. Untuk testing manual dengan curl, gunakan flag `-c` (simpan cookie) dan `-b` (kirim cookie):
727
-
728
- ```bash
729
- # Login — cookie otomatis disimpan ke file cookies.txt
730
- curl -c cookies.txt -X POST http://localhost:3000/auth/login \
731
- -H "Content-Type: application/json" \
732
- -d '{ "identifier": "alice@example.com", "password": "password123" }'
733
- ```
734
-
735
- ```json
736
- // Response — tidak ada refreshToken di body
737
- {
738
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
739
- "user": { "id": "clx1234abcd", "identifier": "alice@example.com", "roles": ["user"] }
740
- }
741
- ```
742
-
743
- ```bash
744
- # Refresh — cookie dikirim otomatis dari file cookies.txt
745
- curl -c cookies.txt -b cookies.txt -X POST http://localhost:3000/auth/refresh
746
- ```
747
-
748
- ```json
749
- // Response — access token baru, cookie baru di-set
750
- { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }
751
- ```
752
-
753
- ```bash
754
- # Logout — cookie dikirim dan dihapus dari server
755
- curl -c cookies.txt -b cookies.txt -X POST http://localhost:3000/auth/logout
756
- ```
757
-
758
- ### Opsi cookie lengkap
759
-
760
- ```typescript
761
- cookie: {
762
- name?: string; // nama cookie, default 'refresh_token'
763
- httpOnly?: boolean; // default true — tidak bisa diakses JavaScript
764
- secure?: boolean; // default false — set true di production (HTTPS)
765
- sameSite?: 'strict' | 'lax' | 'none'; // default 'strict'
766
- path?: string; // default '/'
767
- }
768
- ```
769
-
770
- > **SameSite recommendation:**
771
- > - `'strict'` — paling aman, cocok untuk web app yang tidak butuh cross-site auth
772
- > - `'lax'` — cocok jika frontend dan backend berbeda subdomain
773
- > - `'none'` — butuh `secure: true`, cocok untuk third-party integrations (SaaS widget, iframe)
429
+ `verifyAccessToken` and `verifyRefreshToken` throw `AuthError` with code `TOKEN_EXPIRED` or `TOKEN_INVALID` wrap them in a try/catch or use the router which handles this automatically.
774
430
 
775
431
  ---
776
432
 
777
- ## Identifier Fleksibel
778
-
779
- Field `identifier` bisa berisi apapun — library tidak peduli formatnya. Adapter yang menentukan apa yang dimaksud "identifier" di database kamu.
780
-
781
- ### Login dengan email
782
-
783
- ```typescript
784
- // adapter.ts
785
- async findByIdentifier(identifier) {
786
- return prisma.user.findUnique({ where: { email: identifier }, ... });
787
- }
788
- ```
789
-
790
- ```bash
791
- curl -X POST /auth/login -d '{ "identifier": "alice@example.com", "password": "..." }'
792
- ```
793
-
794
- ### Login dengan username
433
+ ## Types
795
434
 
796
435
  ```typescript
797
- // adapter.ts
798
- async findByIdentifier(identifier) {
799
- return prisma.user.findUnique({ where: { username: identifier }, ... });
800
- }
801
- ```
802
-
803
- ```bash
804
- curl -X POST /auth/login -d '{ "identifier": "alice123", "password": "..." }'
805
- ```
806
-
807
- ### Login dengan email ATAU username (multi-kolom)
808
-
809
- ```typescript
810
- // adapter.ts
811
- async findByIdentifier(identifier) {
812
- // Prisma: cari di kolom email atau username
813
- const raw = await prisma.user.findFirst({
814
- where: {
815
- OR: [{ email: identifier }, { username: identifier }],
816
- },
817
- include: { userRoles: { include: { role: true } } },
818
- });
819
- return raw ? toUserRecord(raw) : null;
820
- }
821
- ```
822
-
823
- ```bash
824
- # Bisa pakai email
825
- curl -X POST /auth/login -d '{ "identifier": "alice@example.com", "password": "..." }'
826
-
827
- # Atau username — endpoint sama
828
- curl -X POST /auth/login -d '{ "identifier": "alice123", "password": "..." }'
829
- ```
830
-
831
- ### Login dengan nomor HP
832
-
833
- ```typescript
834
- async findByIdentifier(identifier) {
835
- return prisma.user.findUnique({ where: { phone: identifier }, ... });
836
- }
837
- ```
838
-
839
- ---
840
-
841
- ## Konfigurasi
842
-
843
- ```typescript
844
- createAuth({
845
- // WAJIB
846
- secret: string, // JWT signing secret, min 32 karakter, simpan di env
847
- validRoles: TRole[], // daftar role yang sah di aplikasimu, gunakan as const
848
- adapter: AuthAdapter, // implementasi database
849
-
850
- // OPSIONAL
851
- accessExpiresIn?: '15m', // masa berlaku access token, default '15m'
852
- refreshExpiresIn?: '7d', // masa berlaku refresh token, default '7d'
853
- saltRounds?: 12, // bcrypt cost factor, default 12, min 10 maks 31
854
- algorithm?: 'HS256', // algoritma JWT, default 'HS256'
855
-
856
- // COOKIE MODE (opsional) — simpan refresh token di httpOnly cookie
857
- cookie?: {
858
- name?: string, // nama cookie, default 'refresh_token'
859
- httpOnly?: boolean, // default true
860
- secure?: boolean, // default false, set true di production
861
- sameSite?: 'strict' | 'lax' | 'none', // default 'strict'
862
- path?: string, // default '/'
863
- }
864
- })
865
- ```
866
-
867
- **Format `expiresIn`:**
868
-
869
- | Format | Arti |
870
- |---|---|
871
- | `'30s'` | 30 detik |
872
- | `'15m'` | 15 menit |
873
- | `'2h'` | 2 jam |
874
- | `'7d'` | 7 hari |
875
- | `'2w'` | 2 minggu |
876
- | `3600` | 3600 detik (angka = detik) |
877
-
878
- Validasi konfigurasi dijalankan saat `createAuth()` dipanggil — kesalahan terdeteksi langsung saat server start, bukan saat request pertama datang.
879
-
880
- ---
881
-
882
- ## AuthAdapter Interface
883
-
884
- Ini adalah kontrak yang harus kamu implementasi. Berisi 7 method:
885
-
886
- ```typescript
887
- interface AuthAdapter {
888
- user: {
889
- findByIdentifier(identifier: string): Promise<UserRecord | null>;
890
- findById(id: string): Promise<UserRecord | null>;
891
- create(data: CreateUserData): Promise<{ id: string }>;
892
- };
893
- session: {
894
- create(data: { userId: string; expiresAt: Date }): Promise<{ id: string }>;
895
- findById(sessionId: string): Promise<(SessionRecord & { user: UserRecord }) | null>;
896
- delete(sessionId: string): Promise<void>;
897
- deleteAllForUser(userId: string): Promise<void>;
898
- };
899
- }
900
- ```
901
-
902
- **Type yang terlibat:**
903
-
904
- ```typescript
905
- // Yang dikembalikan oleh adapter ke library
906
- interface UserRecord {
907
- id: string;
908
- identifier: string; // email / username / phone — apapun yang kamu pakai
909
- passwordHash: string;
910
- roles: string[];
911
- }
912
-
913
- interface SessionRecord {
914
- id: string;
915
- userId: string;
916
- expiresAt: Date;
917
- createdAt: Date;
918
- }
436
+ import type {
437
+ AuthConfig,
438
+ AuthClient,
439
+ AuthAdapter,
440
+ AuthUser,
441
+ AuthResult,
442
+ SignupResult,
443
+ AssignRolesResult,
444
+ RefreshResult,
445
+ ApiResponse,
446
+ SignupInput,
447
+ LoginInput,
448
+ RouterHandlers,
449
+ UserRecord,
450
+ SessionRecord,
451
+ CreateUserData,
452
+ CookieConfig,
453
+ PermitCheck,
454
+ PermitOptions,
455
+ AuthErrorCode,
456
+ } from 'sentri';
919
457
 
920
- // Yang diterima adapter dari library saat membuat user
921
- interface CreateUserData {
922
- identifier: string;
923
- passwordHash: string; // sudah di-hash, jangan hash lagi
924
- roles: string[];
925
- }
458
+ import { AuthError, createAuth } from 'sentri';
926
459
  ```
927
460
 
928
461
  ---
929
462
 
930
463
  ## Error Handling
931
464
 
932
- Semua error dari library dilempar sebagai instance `AuthError` dan diteruskan via `next(err)`.
465
+ All errors thrown by the library are instances of `AuthError` with a machine-readable `code`:
933
466
 
934
- ### Menangkap di error handler global
467
+ | Code | HTTP | Meaning |
468
+ |---|---|---|
469
+ | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
470
+ | `USER_ALREADY_EXISTS` | 409 | Signup with duplicate identifier |
471
+ | `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
472
+ | `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
473
+ | `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
474
+ | `UNAUTHORIZED` | 401 | No valid access token on the request |
475
+ | `FORBIDDEN` | 403 | Authenticated but missing required role |
476
+ | `INVALID_ROLE` | 400 | Role name not in `validRoles` |
477
+ | `VALIDATION_ERROR` | 400 | Missing or invalid input field |
478
+ | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` configuration |
479
+
480
+ The built-in router converts all `AuthError` instances to the standard envelope automatically. For custom routes:
935
481
 
936
482
  ```typescript
937
483
  import { AuthError } from 'sentri';
938
484
 
939
- app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
940
- if (err instanceof AuthError) {
941
- const statusMap: Partial<Record<typeof err.code, number>> = {
942
- UNAUTHORIZED: 401,
943
- TOKEN_EXPIRED: 401,
944
- TOKEN_INVALID: 401,
945
- FORBIDDEN: 403,
946
- USER_ALREADY_EXISTS: 409,
947
- };
948
- const status = statusMap[err.code] ?? 400;
949
- res.status(status).json({ code: err.code, message: err.message });
950
- return;
485
+ app.use((error, _request, response, next) => {
486
+ if (error instanceof AuthError) {
487
+ const status =
488
+ error.code === 'UNAUTHORIZED' || error.code === 'TOKEN_EXPIRED' || error.code === 'TOKEN_INVALID' || error.code === 'INVALID_CREDENTIALS' ? 401
489
+ : error.code === 'FORBIDDEN' ? 403
490
+ : error.code === 'USER_NOT_FOUND' ? 404
491
+ : error.code === 'USER_ALREADY_EXISTS' ? 409
492
+ : 400;
493
+
494
+ return response.status(status).json({ error: true, statusCode: status, message: error.message, data: null });
951
495
  }
952
- // Error lain (database error, dsb)
953
- console.error(err);
954
- res.status(500).json({ message: 'Internal server error' });
496
+ next(error);
955
497
  });
956
498
  ```
957
-
958
- ### Daftar error code
959
-
960
- | Code | Status HTTP Umum | Kapan muncul |
961
- |---|---|---|
962
- | `UNAUTHORIZED` | 401 | Tidak ada atau token tidak valid di header |
963
- | `TOKEN_EXPIRED` | 401 | Access token / refresh token sudah expire |
964
- | `TOKEN_INVALID` | 401 | Signature JWT salah, format rusak, atau tipe token keliru |
965
- | `INVALID_CREDENTIALS` | 401 | Identifier atau password salah |
966
- | `FORBIDDEN` | 403 | Login tapi tidak punya role / izin yang dibutuhkan |
967
- | `USER_ALREADY_EXISTS` | 409 | Signup dengan identifier yang sudah terdaftar |
968
- | `USER_NOT_FOUND` | 404 | Operasi butuh user yang tidak ada |
969
- | `INVALID_ROLE` | 400 | Signup meminta role yang tidak ada di `validRoles` |
970
- | `VALIDATION_ERROR` | 400 | Field wajib kosong atau format tidak valid |
971
- | `CONFIGURATION_ERROR` | — | `createAuth()` dipanggil dengan config yang tidak valid (throw di startup) |
972
-
973
- > `INVALID_CREDENTIALS` sengaja dipakai untuk dua kondisi (identifier tidak ditemukan **dan** password salah). Ini mencegah attacker menebak identifier mana yang terdaftar.
974
-
975
- ---
976
-
977
- ## Type Exports
978
-
979
- Semua type yang perlu diimport dari library:
980
-
981
- ```typescript
982
- import {
983
- // Fungsi utama
984
- createAuth,
985
- AuthError,
986
-
987
- // Types untuk konfigurasi
988
- type AuthConfig,
989
- type CookieConfig,
990
- type AuthAdapter,
991
- type UserRecord,
992
- type SessionRecord,
993
- type CreateUserData,
994
-
995
- // Types untuk auth flow
996
- type AuthUser, // { id, identifier, roles } — isi req.user
997
- type AuthResult, // return type signup/login
998
- type RefreshResult, // return type refresh
999
- type SignupInput,
1000
- type LoginInput,
1001
-
1002
- // Types untuk permit middleware
1003
- type PermitCheck,
1004
- type PermitOptions,
1005
-
1006
- // Types untuk error
1007
- type AuthErrorCode,
1008
- type AuthClient,
1009
- } from 'sentri';
1010
- ```
1011
-
1012
- ### Menggunakan tipe di luar library
1013
-
1014
- ```typescript
1015
- import type { AuthUser, PermitCheck } from 'sentri';
1016
- import type { AppRole } from './lib/auth.js';
1017
-
1018
- // Tipe req.user sudah otomatis tersedia setelah import dari sentri
1019
- // Tidak perlu extend Request manual
1020
- function getUser(req: express.Request): AuthUser<AppRole> | undefined {
1021
- return req.user as AuthUser<AppRole> | undefined;
1022
- }
1023
-
1024
- // Buat fungsi permission yang bisa di-reuse
1025
- const isOwner: PermitCheck = (req) => req.user!.id === req.params['userId'];
1026
-
1027
- router.put('/:userId', auth.protect(), auth.permit(isOwner), handler);
1028
- router.delete('/:userId', auth.protect(), auth.permit(isOwner), handler);
1029
- ```
1030
-
1031
- ---
1032
-
1033
- ## Keamanan
1034
-
1035
- | Aspek | Implementasi |
1036
- |---|---|
1037
- | **Password hashing** | bcrypt, cost factor 12 (min 10), max input 72 karakter (batas bcrypt) |
1038
- | **Token signing** | HMAC — satu secret menghasilkan dua key terpisah: `secret:access` dan `secret:refresh` |
1039
- | **Anti-enumeration** | `INVALID_CREDENTIALS` dipakai untuk password salah **dan** identifier tidak ditemukan |
1040
- | **Refresh token revocation** | Berbasis session di DB — hapus row = token langsung tidak berlaku, tanpa blacklist |
1041
- | **Token rotation** | Setiap refresh, session lama dihapus dan session baru dibuat |
1042
- | **Config validation** | Secret min 32 karakter, saltRounds 10–31, validRoles tidak boleh kosong — dicek saat startup |
1043
- | **Input sanitization** | Identifier di-trim whitespace; max identifier 255 karakter, max password 72 karakter |
1044
- | **Cookie mode** | httpOnly cookie mencegah akses JavaScript; `SameSite=strict` secara default; `maxAge` sesuai `refreshExpiresIn` |