sentri 1.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.
Files changed (50) hide show
  1. package/README.md +1044 -0
  2. package/dist/client.d.ts +158 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +49 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/errors/AuthError.d.ts +40 -0
  7. package/dist/errors/AuthError.d.ts.map +1 -0
  8. package/dist/errors/AuthError.js +29 -0
  9. package/dist/errors/AuthError.js.map +1 -0
  10. package/dist/index.d.ts +15 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +3 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/libs/config.d.ts +18 -0
  15. package/dist/libs/config.d.ts.map +1 -0
  16. package/dist/libs/config.js +59 -0
  17. package/dist/libs/config.js.map +1 -0
  18. package/dist/libs/hash.d.ts +3 -0
  19. package/dist/libs/hash.d.ts.map +1 -0
  20. package/dist/libs/hash.js +8 -0
  21. package/dist/libs/hash.js.map +1 -0
  22. package/dist/libs/token.d.ts +8 -0
  23. package/dist/libs/token.d.ts.map +1 -0
  24. package/dist/libs/token.js +54 -0
  25. package/dist/libs/token.js.map +1 -0
  26. package/dist/middleware/authorize.d.ts +3 -0
  27. package/dist/middleware/authorize.d.ts.map +1 -0
  28. package/dist/middleware/authorize.js +15 -0
  29. package/dist/middleware/authorize.js.map +1 -0
  30. package/dist/middleware/permit.d.ts +62 -0
  31. package/dist/middleware/permit.d.ts.map +1 -0
  32. package/dist/middleware/permit.js +61 -0
  33. package/dist/middleware/permit.js.map +1 -0
  34. package/dist/middleware/protect.d.ts +4 -0
  35. package/dist/middleware/protect.d.ts.map +1 -0
  36. package/dist/middleware/protect.js +19 -0
  37. package/dist/middleware/protect.js.map +1 -0
  38. package/dist/middleware/router.d.ts +27 -0
  39. package/dist/middleware/router.d.ts.map +1 -0
  40. package/dist/middleware/router.js +244 -0
  41. package/dist/middleware/router.js.map +1 -0
  42. package/dist/services/auth.d.ts +7 -0
  43. package/dist/services/auth.d.ts.map +1 -0
  44. package/dist/services/auth.js +84 -0
  45. package/dist/services/auth.js.map +1 -0
  46. package/dist/types/auth.d.ts +234 -0
  47. package/dist/types/auth.d.ts.map +1 -0
  48. package/dist/types/auth.js +2 -0
  49. package/dist/types/auth.js.map +1 -0
  50. package/package.json +38 -0
package/README.md ADDED
@@ -0,0 +1,1044 @@
1
+ # sentri
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
15
+
16
+ ---
17
+
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)
33
+
34
+ ---
35
+
36
+ ## Instalasi
37
+
38
+ ```bash
39
+ npm install sentri express
40
+ npm install -D @types/express typescript
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Cara Kerja
46
+
47
+ Sebelum mulai, penting memahami dua konsep utama:
48
+
49
+ ### Dua token, satu secret
50
+
51
+ ```
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
+
58
+ ### Access token vs Refresh token
59
+
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 |
66
+
67
+ ### Session-based revocation
68
+
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.
70
+
71
+ ```
72
+ Login → buat session di DB → refresh token berisi sessionId
73
+ Logout → hapus session di DB → refresh token langsung tidak berlaku
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Setup Lengkap dari Nol
79
+
80
+ ### Langkah 1: Struktur project
81
+
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
94
+
95
+ ```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
100
+ ```
101
+
102
+ > **Penting:** `JWT_SECRET` harus minimal 32 karakter. `createAuth()` akan throw `CONFIGURATION_ERROR` jika kurang dari itu.
103
+
104
+ ### Langkah 3: Siapkan tabel database
105
+
106
+ Library membutuhkan empat tabel: **User**, **Role**, **UserRole**, dan **Session**.
107
+
108
+ **Contoh Prisma schema** (`prisma/schema.prisma`):
109
+
110
+ ```prisma
111
+ generator client {
112
+ provider = "prisma-client-js"
113
+ }
114
+
115
+ datasource db {
116
+ provider = "postgresql"
117
+ url = env("DATABASE_URL")
118
+ }
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
+ }
129
+
130
+ model Role {
131
+ id String @id @default(cuid())
132
+ name String @unique
133
+ userRoles UserRole[]
134
+ }
135
+
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)
141
+
142
+ @@id([userId, roleId])
143
+ }
144
+
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
+ ```
153
+
154
+ ```bash
155
+ npx prisma db push # atau prisma migrate dev
156
+ npx prisma generate
157
+ ```
158
+
159
+ ### Langkah 4: Buat adapter
160
+
161
+ Adapter adalah jembatan antara library dan database kamu. Library mendefinisikan interface-nya, kamu mengisi implementasinya.
162
+
163
+ ```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
+ }
182
+
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
+ });
217
+ }
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 };
228
+ },
229
+
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 } });
249
+ },
250
+
251
+ // Dipanggil saat logout-all
252
+ async deleteAllForUser(userId) {
253
+ await prisma.session.deleteMany({ where: { userId } });
254
+ },
255
+ },
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
+ });
279
+ ```
280
+
281
+ Setelah ini, TypeScript tahu persis role apa yang valid. `auth.authorize('superuser')` adalah **compile error**.
282
+
283
+ ### Langkah 6: Pasang di Express
284
+
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';
293
+
294
+ const app = express();
295
+
296
+ // express.json() WAJIB dipasang sebelum auth.router()
297
+ app.use(express.json());
298
+
299
+ // 6 endpoint auth langsung jadi — tidak perlu tulis manual
300
+ app.use('/auth', auth.router());
301
+
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
+ });
322
+
323
+ const port = Number(process.env.PORT ?? 3000);
324
+ app.listen(port, () => console.log(`Server running on http://localhost:${port}`));
325
+
326
+ process.on('SIGINT', async () => {
327
+ await prisma.$disconnect();
328
+ process.exit(0);
329
+ });
330
+ ```
331
+
332
+ ### Langkah 7: Buat protected routes
333
+
334
+ ```typescript
335
+ // src/routes/user.ts
336
+ import { Router } from 'express';
337
+ import { auth } from '../lib/auth.js';
338
+
339
+ export const userRouter = Router();
340
+
341
+ // Hanya user yang sudah login
342
+ userRouter.get('/me', auth.protect(), (req, res) => {
343
+ res.json(req.user); // { id, identifier, roles }
344
+ });
345
+
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
+ );
352
+
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
+ );
359
+
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
+ );
369
+ ```
370
+
371
+ ---
372
+
373
+ ## Contoh HTTP Request & Response
374
+
375
+ Gunakan `curl`, Postman, atau HTTP client lain. Semua request body harus JSON.
376
+
377
+ ### Signup
378
+
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
+ ```
384
+
385
+ ```json
386
+ // 201 Created
387
+ {
388
+ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
389
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
390
+ "user": {
391
+ "id": "clx1234abcd",
392
+ "identifier": "alice@example.com",
393
+ "roles": ["user"]
394
+ }
395
+ }
396
+ ```
397
+
398
+ ```json
399
+ // 409 Conflict — identifier sudah terdaftar
400
+ { "code": "USER_ALREADY_EXISTS", "message": "User already exists" }
401
+ ```
402
+
403
+ ```json
404
+ // 400 Bad Request — role tidak ada di validRoles
405
+ { "code": "INVALID_ROLE", "message": "Invalid roles: superuser" }
406
+ ```
407
+
408
+ ### Login
409
+
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
+ ```
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
+ }
427
+ ```
428
+
429
+ ```json
430
+ // 401 Unauthorized — identifier atau password salah
431
+ { "code": "INVALID_CREDENTIALS", "message": "Invalid credentials" }
432
+ ```
433
+
434
+ ### Akses endpoint protected
435
+
436
+ Kirim `accessToken` di header `Authorization`:
437
+
438
+ ```bash
439
+ curl http://localhost:3000/user/me \
440
+ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
441
+ ```
442
+
443
+ ```json
444
+ // 200 OK
445
+ { "id": "clx1234abcd", "identifier": "alice@example.com", "roles": ["user"] }
446
+ ```
447
+
448
+ ```json
449
+ // 401 Unauthorized — token tidak ada atau tidak valid
450
+ { "code": "UNAUTHORIZED", "message": "Missing or malformed Authorization header" }
451
+ ```
452
+
453
+ ```json
454
+ // 401 Unauthorized — token sudah expire
455
+ { "code": "TOKEN_EXPIRED", "message": "Token has expired" }
456
+ ```
457
+
458
+ ```json
459
+ // 403 Forbidden — tidak punya role yang dibutuhkan
460
+ { "code": "FORBIDDEN", "message": "Requires one of roles: admin" }
461
+ ```
462
+
463
+ ### Refresh token
464
+
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).
466
+
467
+ ```bash
468
+ curl -X POST http://localhost:3000/auth/refresh \
469
+ -H "Content-Type: application/json" \
470
+ -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'
471
+ ```
472
+
473
+ ```json
474
+ // 200 OK — simpan kedua token baru, buang yang lama
475
+ {
476
+ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
477
+ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
478
+ }
479
+ ```
480
+
481
+ ```json
482
+ // 401 Unauthorized — refresh token sudah dipakai / session di-logout
483
+ { "code": "UNAUTHORIZED", "message": "Session not found or revoked" }
484
+ ```
485
+
486
+ ### Logout
487
+
488
+ Hapus session saat ini. Refresh token yang terkait langsung tidak berlaku.
489
+
490
+ ```bash
491
+ curl -X POST http://localhost:3000/auth/logout \
492
+ -H "Content-Type: application/json" \
493
+ -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'
494
+ ```
495
+
496
+ ```json
497
+ // 200 OK
498
+ { "message": "logged out" }
499
+ ```
500
+
501
+ ### Logout semua device
502
+
503
+ Perlu access token yang valid. Menghapus semua session user — efektif logout dari semua device sekaligus.
504
+
505
+ ```bash
506
+ curl -X POST http://localhost:3000/auth/logout-all \
507
+ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
508
+ ```
509
+
510
+ ```json
511
+ // 200 OK
512
+ { "message": "all sessions revoked" }
513
+ ```
514
+
515
+ ### Cek user saat ini
516
+
517
+ Mengembalikan data user yang sedang login berdasarkan access token. Endpoint ini membutuhkan access token yang valid.
518
+
519
+ ```bash
520
+ curl http://localhost:3000/auth/me \
521
+ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
522
+ ```
523
+
524
+ ```json
525
+ // 200 OK
526
+ {
527
+ "id": "clx1234abcd",
528
+ "identifier": "alice@example.com",
529
+ "roles": ["user"]
530
+ }
531
+ ```
532
+
533
+ ```json
534
+ // 401 Unauthorized — token tidak ada atau tidak valid
535
+ { "code": "UNAUTHORIZED", "message": "Missing or malformed Authorization header" }
536
+ ```
537
+
538
+ ---
539
+
540
+ ## Middleware
541
+
542
+ Library menyediakan tiga middleware yang bisa dikombinasikan:
543
+
544
+ ```
545
+ protect() → autentikasi: siapa yang request?
546
+ authorize() → otorisasi role: role apa yang boleh?
547
+ permit() → otorisasi resource: boleh akses data ini?
548
+ ```
549
+
550
+ Ketiganya bekerja seperti rantai. `authorize` dan `permit` selalu dipasang **setelah** `protect`.
551
+
552
+ ### `auth.protect()`
553
+
554
+ Verifikasi access token dari header `Authorization: Bearer <token>`. Jika valid, `req.user` tersedia di handler berikutnya.
555
+
556
+ ```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
+ });
564
+ });
565
+ ```
566
+
567
+ ### `auth.authorize(...roles)`
568
+
569
+ Cek apakah user punya minimal satu dari role yang disebutkan.
570
+
571
+ ```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);
577
+ ```
578
+
579
+ ### `auth.permit(check | options)`
580
+
581
+ Cek kepemilikan resource atau kondisi apapun yang butuh data dari request. Cocok untuk aturan seperti "hanya pemilik yang bisa edit".
582
+
583
+ **Bentuk 1 — fungsi check saja:**
584
+
585
+ ```typescript
586
+ // User hanya bisa akses data miliknya sendiri
587
+ router.get('/orders/:orderId',
588
+ 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,
594
+ );
595
+ ```
596
+
597
+ **Bentuk 2 — dengan role bypass:**
598
+
599
+ ```typescript
600
+ // Admin bisa akses semua; user biasa hanya miliknya
601
+ router.delete('/orders/:orderId',
602
+ auth.protect(),
603
+ 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
+ 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;
627
+ },
628
+ }),
629
+ updateArticleHandler,
630
+ );
631
+ ```
632
+
633
+ ---
634
+
635
+ ## Custom Routes
636
+
637
+ Jika `auth.router()` tidak sesuai kebutuhan, kamu bisa tulis route sendiri menggunakan method `auth.signup()`, `auth.login()`, dll secara langsung.
638
+
639
+ ```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
+ });
675
+
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
+ });
709
+ ```
710
+
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)
774
+
775
+ ---
776
+
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
795
+
796
+ ```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
+ }
919
+
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
+ }
926
+ ```
927
+
928
+ ---
929
+
930
+ ## Error Handling
931
+
932
+ Semua error dari library dilempar sebagai instance `AuthError` dan diteruskan via `next(err)`.
933
+
934
+ ### Menangkap di error handler global
935
+
936
+ ```typescript
937
+ import { AuthError } from 'sentri';
938
+
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;
951
+ }
952
+ // Error lain (database error, dsb)
953
+ console.error(err);
954
+ res.status(500).json({ message: 'Internal server error' });
955
+ });
956
+ ```
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` |