sentri 1.0.3 → 1.0.5

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,1044 +1,448 @@
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
+ - [Adapter Interface](#adapter-interface)
14
+ - [Pre-built Router](#pre-built-router)
15
+ - [Middleware](#middleware)
16
+ - [Programmatic API](#programmatic-api)
17
+ - [Types](#types)
18
+ - [Error Handling](#error-handling)
33
19
 
34
20
  ---
35
21
 
36
- ## Instalasi
22
+ ## Installation
37
23
 
38
24
  ```bash
39
- npm install sentri express
40
- npm install -D @types/express typescript
25
+ npm install sentri
41
26
  ```
42
27
 
28
+ **Peer dependency:** `express >= 4.0.0`
29
+
43
30
  ---
44
31
 
45
- ## Cara Kerja
32
+ ## Quick Start
46
33
 
47
- Sebelum mulai, penting memahami dua konsep utama:
34
+ ### 1. Generate templates
48
35
 
49
- ### Dua token, satu secret
36
+ ```bash
37
+ # Prisma
38
+ npx sentri generate prisma
50
39
 
40
+ # Drizzle
41
+ npx sentri generate drizzle
51
42
  ```
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
43
 
58
- ### Access token vs Refresh token
44
+ This creates:
59
45
 
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 |
46
+ ```
47
+ src/lib/
48
+ index.ts ← barrel export
49
+ sentri/
50
+ adapter.ts ← AuthAdapter implementation
51
+ auth.ts ← configured auth client
52
+ schema.ts ← table definitions (Drizzle only)
53
+ prisma/
54
+ schema.prisma ← Prisma models (Prisma only, created or appended)
55
+ ```
66
56
 
67
- ### Session-based revocation
57
+ ### 2. Mount the router
68
58
 
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.
59
+ ```typescript
60
+ import express from 'express';
61
+ import { auth } from './lib/sentri/auth.js';
70
62
 
71
- ```
72
- Login → buat session di DB → refresh token berisi sessionId
73
- Logout → hapus session di DB → refresh token langsung tidak berlaku
63
+ const app = express();
64
+ app.use(express.json());
65
+ app.use('/auth', auth.router());
74
66
  ```
75
67
 
68
+ Done. All endpoints are available at `/auth/*`.
69
+
76
70
  ---
77
71
 
78
- ## Setup Lengkap dari Nol
72
+ ## CLI
79
73
 
80
- ### Langkah 1: Struktur project
74
+ ### `sentri generate <prisma|drizzle>`
81
75
 
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
76
+ Generates adapter, auth config, and schema templates in one command.
94
77
 
95
78
  ```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
79
+ npx sentri generate prisma
80
+ npx sentri generate drizzle
100
81
  ```
101
82
 
102
- > **Penting:** `JWT_SECRET` harus minimal 32 karakter. `createAuth()` akan throw `CONFIGURATION_ERROR` jika kurang dari itu.
103
-
104
- ### Langkah 3: Siapkan tabel database
83
+ **What gets created:**
105
84
 
106
- Library membutuhkan empat tabel: **User**, **Role**, **UserRole**, dan **Session**.
107
-
108
- **Contoh Prisma schema** (`prisma/schema.prisma`):
85
+ | File | Behavior |
86
+ |---|---|
87
+ | `src/lib/sentri/adapter.ts` | Created fresh (error if exists) |
88
+ | `src/lib/sentri/auth.ts` | Created fresh (error if exists) |
89
+ | `src/lib/sentri/schema.ts` | Drizzle only — created fresh or tables appended |
90
+ | `prisma/schema.prisma` | Prisma only — created fresh or models appended |
91
+ | `src/lib/index.ts` | Created fresh, skipped if already exists |
109
92
 
110
- ```prisma
111
- generator client {
112
- provider = "prisma-client-js"
113
- }
93
+ ---
114
94
 
115
- datasource db {
116
- provider = "postgresql"
117
- url = env("DATABASE_URL")
118
- }
95
+ ## Configuration
119
96
 
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
- }
97
+ ```typescript
98
+ import { createAuth } from 'sentri';
135
99
 
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)
100
+ export const auth = createAuth({
101
+ secret: process.env.JWT_SECRET!, // required — keep in env
102
+ validRoles: ['user', 'admin'] as const, // required — use `as const` for type safety
103
+ adapter: myAdapter, // required see Adapter Interface
104
+
105
+ // optional
106
+ accessExpiresIn: '15m', // default: '15m'
107
+ refreshExpiresIn: '7d', // default: '7d'
108
+ algorithm: 'HS256', // default: 'HS256' — also 'HS384' | 'HS512'
109
+ saltRounds: 12, // default: 12 (bcrypt rounds, min 10)
110
+
111
+ cookie: { // optional — enables httpOnly cookie for refresh token
112
+ secure: process.env.NODE_ENV === 'production',
113
+ // name: 'refresh_token', // default: 'refresh_token'
114
+ // httpOnly: true, // default: true
115
+ // sameSite: 'strict', // default: 'strict'
116
+ // path: '/', // default: '/'
117
+ },
118
+ });
119
+ ```
141
120
 
142
- @@id([userId, roleId])
143
- }
121
+ `accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'15m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
144
122
 
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
- ```
123
+ When `cookie` is configured, the refresh token is stored in an httpOnly cookie automatically. No `cookie-parser` middleware is needed.
153
124
 
154
- ```bash
155
- npx prisma db push # atau prisma migrate dev
156
- npx prisma generate
157
- ```
125
+ ---
158
126
 
159
- ### Langkah 4: Buat adapter
127
+ ## Adapter Interface
160
128
 
161
- Adapter adalah jembatan antara library dan database kamu. Library mendefinisikan interface-nya, kamu mengisi implementasinya.
129
+ The adapter connects sentri to your database. Implement `AuthAdapter` for any ORM or data layer.
162
130
 
163
131
  ```typescript
164
- // src/lib/adapter.ts
165
132
  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
133
 
183
- export const adapter: AuthAdapter = {
134
+ const adapter: AuthAdapter = {
184
135
  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
- },
136
+ findByIdentifier(identifier: string): Promise<UserRecord | null>,
137
+ findById(id: string): Promise<UserRecord | null>,
138
+ create(data: CreateUserData): Promise<{ id: string }>,
139
+ updateRoles(userId: string, roles: string[]): Promise<void>,
221
140
  },
222
-
223
141
  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
- },
142
+ create(data: { userId: string; expiresAt: Date }): Promise<{ id: string }>,
143
+ findById(sessionId: string): Promise<(SessionRecord & { user: UserRecord }) | null>,
144
+ delete(sessionId: string): Promise<void>,
145
+ deleteAllForUser(userId: string): Promise<void>,
255
146
  },
256
147
  };
257
148
  ```
258
149
 
259
- ### Langkah 5: Buat auth client
150
+ `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.
260
151
 
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
- ```
152
+ ### Using the generated adapter
280
153
 
281
- Setelah ini, TypeScript tahu persis role apa yang valid. `auth.authorize('superuser')` adalah **compile error**.
282
-
283
- ### Langkah 6: Pasang di Express
154
+ The generated `adapter.ts` exports `createAdapter(db)` so you can pass your existing database instance:
284
155
 
285
156
  ```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';
157
+ // Prisma
158
+ import { PrismaClient } from '@prisma/client';
159
+ import { createAdapter } from './adapter.js';
293
160
 
294
- const app = express();
161
+ const prisma = new PrismaClient();
162
+ export const adapter = createAdapter(prisma);
295
163
 
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);
164
+ // Drizzle
165
+ import { db } from '../db.js';
166
+ import { createAdapter } from './adapter.js';
304
167
 
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
- });
168
+ export const adapter = createAdapter(db);
330
169
  ```
331
170
 
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
- ```
171
+ `createAdapter` throws an `AdapterConfigError` at runtime if called without a `db` argument.
370
172
 
371
173
  ---
372
174
 
373
- ## Contoh HTTP Request & Response
175
+ ## Pre-built Router
374
176
 
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
- ```
177
+ `auth.router()` returns an Express Router with all standard endpoints. Every response uses this envelope:
384
178
 
385
- ```json
386
- // 201 Created
179
+ ```typescript
387
180
  {
388
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
389
- "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
390
- "user": {
391
- "id": "clx1234abcd",
392
- "identifier": "alice@example.com",
393
- "roles": ["user"]
394
- }
181
+ error: boolean,
182
+ statusCode: number,
183
+ message: string,
184
+ data: T | null
395
185
  }
396
186
  ```
397
187
 
398
- ```json
399
- // 409 Conflict — identifier sudah terdaftar
400
- { "code": "USER_ALREADY_EXISTS", "message": "User already exists" }
401
- ```
188
+ ### Endpoints
402
189
 
403
- ```json
404
- // 400 Bad Request — role tidak ada di validRoles
405
- { "code": "INVALID_ROLE", "message": "Invalid roles: superuser" }
406
- ```
190
+ #### `POST /signup`
407
191
 
408
- ### Login
192
+ Register a new user. Does **not** issue tokens — call `/login` after signup.
409
193
 
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
194
  ```
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
- }
195
+ Body: { identifier, password, roles?: string[] }
196
+ Returns: { user: { id, identifier, roles } }
197
+ Status: 201
427
198
  ```
428
199
 
429
- ```json
430
- // 401 Unauthorized — identifier atau password salah
431
- { "code": "INVALID_CREDENTIALS", "message": "Invalid credentials" }
432
- ```
200
+ `password` must be 8–72 characters. `roles` must be a subset of `validRoles`.
433
201
 
434
- ### Akses endpoint protected
202
+ ---
435
203
 
436
- Kirim `accessToken` di header `Authorization`:
204
+ #### `POST /login`
437
205
 
438
- ```bash
439
- curl http://localhost:3000/user/me \
440
- -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
441
- ```
206
+ Authenticate a user and start a session.
442
207
 
443
- ```json
444
- // 200 OK
445
- { "id": "clx1234abcd", "identifier": "alice@example.com", "roles": ["user"] }
446
208
  ```
447
-
448
- ```json
449
- // 401 Unauthorized — token tidak ada atau tidak valid
450
- { "code": "UNAUTHORIZED", "message": "Missing or malformed Authorization header" }
209
+ Body: { identifier, password }
210
+ Returns: { accessToken, user: { id, identifier, roles } }
211
+ Status: 200
451
212
  ```
452
213
 
453
- ```json
454
- // 401 Unauthorized — token sudah expire
455
- { "code": "TOKEN_EXPIRED", "message": "Token has expired" }
456
- ```
214
+ The refresh token is stored in an httpOnly cookie. The access token is returned in the response body.
457
215
 
458
- ```json
459
- // 403 Forbidden — tidak punya role yang dibutuhkan
460
- { "code": "FORBIDDEN", "message": "Requires one of roles: admin" }
461
- ```
216
+ ---
462
217
 
463
- ### Refresh token
218
+ #### `POST /refresh`
464
219
 
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).
220
+ 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
221
 
467
- ```bash
468
- curl -X POST http://localhost:3000/auth/refresh \
469
- -H "Content-Type: application/json" \
470
- -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'
471
222
  ```
472
-
473
- ```json
474
- // 200 OK — simpan kedua token baru, buang yang lama
475
- {
476
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
477
- "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
478
- }
223
+ Cookie: refresh_token=<token> (set automatically by /login)
224
+ Returns: { accessToken }
225
+ Status: 200
479
226
  ```
480
227
 
481
- ```json
482
- // 401 Unauthorized — refresh token sudah dipakai / session di-logout
483
- { "code": "UNAUTHORIZED", "message": "Session not found or revoked" }
484
- ```
228
+ The new refresh token is written back to the cookie. No body required.
485
229
 
486
- ### Logout
230
+ ---
487
231
 
488
- Hapus session saat ini. Refresh token yang terkait langsung tidak berlaku.
232
+ #### `POST /logout`
489
233
 
490
- ```bash
491
- curl -X POST http://localhost:3000/auth/logout \
492
- -H "Content-Type: application/json" \
493
- -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'
494
- ```
234
+ Invalidate the current session.
495
235
 
496
- ```json
497
- // 200 OK
498
- { "message": "logged out" }
236
+ ```
237
+ Cookie: refresh_token=<token>
238
+ Returns: null
239
+ Status: 200
499
240
  ```
500
241
 
501
- ### Logout semua device
242
+ Safe to call even if the cookie is missing or the token is already expired.
502
243
 
503
- Perlu access token yang valid. Menghapus semua session user — efektif logout dari semua device sekaligus.
244
+ ---
504
245
 
505
- ```bash
506
- curl -X POST http://localhost:3000/auth/logout-all \
507
- -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
508
- ```
246
+ #### `POST /logout-all`
247
+
248
+ Invalidate all sessions for the authenticated user (logout from every device).
509
249
 
510
- ```json
511
- // 200 OK
512
- { "message": "all sessions revoked" }
250
+ ```
251
+ Headers: Authorization: Bearer <accessToken>
252
+ Returns: null
253
+ Status: 200
513
254
  ```
514
255
 
515
- ### Cek user saat ini
256
+ ---
516
257
 
517
- Mengembalikan data user yang sedang login berdasarkan access token. Endpoint ini membutuhkan access token yang valid.
258
+ #### `GET /me`
518
259
 
519
- ```bash
520
- curl http://localhost:3000/auth/me \
521
- -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
522
- ```
260
+ Return the currently authenticated user.
523
261
 
524
- ```json
525
- // 200 OK
526
- {
527
- "id": "clx1234abcd",
528
- "identifier": "alice@example.com",
529
- "roles": ["user"]
530
- }
531
262
  ```
532
-
533
- ```json
534
- // 401 Unauthorized — token tidak ada atau tidak valid
535
- { "code": "UNAUTHORIZED", "message": "Missing or malformed Authorization header" }
263
+ Headers: Authorization: Bearer <accessToken>
264
+ Returns: { id, identifier, roles }
265
+ Status: 200
536
266
  ```
537
267
 
538
268
  ---
539
269
 
540
- ## Middleware
270
+ #### `POST /users/:userId/roles`
541
271
 
542
- Library menyediakan tiga middleware yang bisa dikombinasikan:
272
+ 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
273
 
544
274
  ```
545
- protect() → autentikasi: siapa yang request?
546
- authorize() → otorisasi role: role apa yang boleh?
547
- permit() → otorisasi resource: boleh akses data ini?
275
+ Headers: Authorization: Bearer <accessToken> (must have admin role)
276
+ Body: { roles: string[] }
277
+ Returns: { user: { id, identifier, roles } }
278
+ Status: 200
548
279
  ```
549
280
 
550
- Ketiganya bekerja seperti rantai. `authorize` dan `permit` selalu dipasang **setelah** `protect`.
281
+ ---
282
+
283
+ ## Middleware
551
284
 
552
285
  ### `auth.protect()`
553
286
 
554
- Verifikasi access token dari header `Authorization: Bearer <token>`. Jika valid, `req.user` tersedia di handler berikutnya.
287
+ Verifies the `Authorization: Bearer <token>` header and injects `req.user` into the request.
555
288
 
556
289
  ```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
- });
290
+ router.get('/dashboard', auth.protect(), (req, res) => {
291
+ res.json(req.user); // { id, identifier, roles }
564
292
  });
565
293
  ```
566
294
 
295
+ ---
296
+
567
297
  ### `auth.authorize(...roles)`
568
298
 
569
- Cek apakah user punya minimal satu dari role yang disebutkan.
299
+ Enforces role-based access. Must be used **after** `auth.protect()`. Passes if the user has at least one of the specified roles.
570
300
 
571
301
  ```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);
302
+ router.delete(
303
+ '/posts/:id',
304
+ auth.protect(),
305
+ auth.authorize('admin'),
306
+ handler,
307
+ );
577
308
  ```
578
309
 
579
- ### `auth.permit(check | options)`
310
+ ---
580
311
 
581
- Cek kepemilikan resource atau kondisi apapun yang butuh data dari request. Cocok untuk aturan seperti "hanya pemilik yang bisa edit".
312
+ ### `auth.permit(check | options)`
582
313
 
583
- **Bentuk 1 fungsi check saja:**
314
+ Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
584
315
 
585
316
  ```typescript
586
- // User hanya bisa akses data miliknya sendiri
587
- router.get('/orders/:orderId',
317
+ // Simple ownership check
318
+ router.put(
319
+ '/users/:id',
588
320
  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,
321
+ auth.permit((request) => request.user!.id === request.params['id']),
322
+ handler,
594
323
  );
595
- ```
596
-
597
- **Bentuk 2 — dengan role bypass:**
598
324
 
599
- ```typescript
600
- // Admin bisa akses semua; user biasa hanya miliknya
601
- router.delete('/orders/:orderId',
325
+ // Admins bypass the check; others must own the resource
326
+ router.delete(
327
+ '/posts/:id',
602
328
  auth.protect(),
603
329
  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
330
  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;
331
+ check: async (request) => {
332
+ const post = await db.post.findUnique({ where: { id: request.params['id'] } });
333
+ return post?.authorId === request.user!.id;
627
334
  },
628
335
  }),
629
- updateArticleHandler,
336
+ handler,
630
337
  );
631
338
  ```
632
339
 
633
340
  ---
634
341
 
635
- ## Custom Routes
342
+ ## Programmatic API
636
343
 
637
- Jika `auth.router()` tidak sesuai kebutuhan, kamu bisa tulis route sendiri menggunakan method `auth.signup()`, `auth.login()`, dll secara langsung.
344
+ All methods are available on the auth client for use outside the built-in router.
638
345
 
639
346
  ```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
- });
347
+ // Auth
348
+ const result = await auth.signup({ identifier: 'user@example.com', password: 'secret123' });
349
+ const result = await auth.login({ identifier: 'user@example.com', password: 'secret123' });
350
+ const result = await auth.refresh(refreshToken);
351
+ await auth.logout(refreshToken);
352
+ await auth.logoutAll(userId);
675
353
 
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' });
354
+ // Roles
355
+ const result = await auth.assignRoles(userId, ['admin']);
356
+ // Merges with existing roles — result.user.roles has the full updated list
681
357
 
682
- const result = await auth.refresh(refreshToken);
683
- if (!result.success) return res.status(401).json({ error: result.error.code });
358
+ // Token utilities
359
+ const accessToken = auth.signAccessToken({ id, identifier, roles });
360
+ const user = auth.verifyAccessToken(accessToken); // throws AuthError if invalid
361
+ const { sessionId } = auth.verifyRefreshToken(token); // throws AuthError if invalid
684
362
 
685
- res.cookie('refreshToken', result.refreshToken, { httpOnly: true, secure: true });
686
- res.json({ accessToken: result.accessToken });
687
- } catch (err) {
688
- next(err);
689
- }
690
- });
363
+ // Password utilities
364
+ const hash = await auth.hashPassword('secret123');
365
+ const valid = await auth.verifyPassword('secret123', hash);
691
366
  ```
692
367
 
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:
368
+ All auth methods return a discriminated union — check `result.success` before accessing data:
698
369
 
699
370
  ```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
371
+ const result = await auth.login({ identifier, password });
725
372
 
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"] }
373
+ if (!result.success) {
374
+ console.error(result.error.code); // 'INVALID_CREDENTIALS'
375
+ console.error(result.error.message);
376
+ return;
740
377
  }
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
378
 
748
- ```json
749
- // Response — access token baru, cookie baru di-set
750
- { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }
379
+ const { accessToken, refreshToken, user } = result;
751
380
  ```
752
381
 
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
382
  ---
776
383
 
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
384
+ ## Types
782
385
 
783
386
  ```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
- }
387
+ import type {
388
+ AuthConfig,
389
+ AuthClient,
390
+ AuthAdapter,
391
+ AuthUser,
392
+ AuthResult,
393
+ SignupResult,
394
+ AssignRolesResult,
395
+ RefreshResult,
396
+ ApiResponse,
397
+ SignupInput,
398
+ LoginInput,
399
+ UserRecord,
400
+ SessionRecord,
401
+ CreateUserData,
402
+ CookieConfig,
403
+ PermitCheck,
404
+ PermitOptions,
405
+ AuthErrorCode,
406
+ } from 'sentri';
919
407
 
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
- }
408
+ import { AuthError, createAuth } from 'sentri';
926
409
  ```
927
410
 
928
411
  ---
929
412
 
930
413
  ## Error Handling
931
414
 
932
- Semua error dari library dilempar sebagai instance `AuthError` dan diteruskan via `next(err)`.
415
+ All errors thrown by the library are instances of `AuthError` with a machine-readable `code`:
933
416
 
934
- ### Menangkap di error handler global
417
+ | Code | HTTP | Meaning |
418
+ |---|---|---|
419
+ | `INVALID_CREDENTIALS` | 401 | Wrong identifier or password |
420
+ | `USER_ALREADY_EXISTS` | 409 | Signup with duplicate identifier |
421
+ | `USER_NOT_FOUND` | 404 | Operation on a non-existent user |
422
+ | `TOKEN_EXPIRED` | 401 | JWT `exp` claim is in the past |
423
+ | `TOKEN_INVALID` | 401 | JWT signature invalid or malformed |
424
+ | `UNAUTHORIZED` | 401 | No valid access token on the request |
425
+ | `FORBIDDEN` | 403 | Authenticated but missing required role |
426
+ | `INVALID_ROLE` | 400 | Role name not in `validRoles` |
427
+ | `VALIDATION_ERROR` | 400 | Missing or invalid input field |
428
+ | `CONFIGURATION_ERROR` | 500 | Invalid `createAuth` configuration |
429
+
430
+ The built-in router converts all `AuthError` instances to the standard envelope automatically. For custom routes:
935
431
 
936
432
  ```typescript
937
433
  import { AuthError } from 'sentri';
938
434
 
939
- app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
435
+ app.use((err, req, res, next) => {
940
436
  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;
437
+ const status =
438
+ err.code === 'UNAUTHORIZED' || err.code === 'TOKEN_EXPIRED' || err.code === 'TOKEN_INVALID' || err.code === 'INVALID_CREDENTIALS' ? 401
439
+ : err.code === 'FORBIDDEN' ? 403
440
+ : err.code === 'USER_NOT_FOUND' ? 404
441
+ : err.code === 'USER_ALREADY_EXISTS' ? 409
442
+ : 400;
443
+
444
+ return res.status(status).json({ error: true, statusCode: status, message: err.message, data: null });
951
445
  }
952
- // Error lain (database error, dsb)
953
- console.error(err);
954
- res.status(500).json({ message: 'Internal server error' });
446
+ next(err);
955
447
  });
956
448
  ```
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` |