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.
- package/README.md +1044 -0
- package/dist/client.d.ts +158 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -0
- package/dist/errors/AuthError.d.ts +40 -0
- package/dist/errors/AuthError.d.ts.map +1 -0
- package/dist/errors/AuthError.js +29 -0
- package/dist/errors/AuthError.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/libs/config.d.ts +18 -0
- package/dist/libs/config.d.ts.map +1 -0
- package/dist/libs/config.js +59 -0
- package/dist/libs/config.js.map +1 -0
- package/dist/libs/hash.d.ts +3 -0
- package/dist/libs/hash.d.ts.map +1 -0
- package/dist/libs/hash.js +8 -0
- package/dist/libs/hash.js.map +1 -0
- package/dist/libs/token.d.ts +8 -0
- package/dist/libs/token.d.ts.map +1 -0
- package/dist/libs/token.js +54 -0
- package/dist/libs/token.js.map +1 -0
- package/dist/middleware/authorize.d.ts +3 -0
- package/dist/middleware/authorize.d.ts.map +1 -0
- package/dist/middleware/authorize.js +15 -0
- package/dist/middleware/authorize.js.map +1 -0
- package/dist/middleware/permit.d.ts +62 -0
- package/dist/middleware/permit.d.ts.map +1 -0
- package/dist/middleware/permit.js +61 -0
- package/dist/middleware/permit.js.map +1 -0
- package/dist/middleware/protect.d.ts +4 -0
- package/dist/middleware/protect.d.ts.map +1 -0
- package/dist/middleware/protect.js +19 -0
- package/dist/middleware/protect.js.map +1 -0
- package/dist/middleware/router.d.ts +27 -0
- package/dist/middleware/router.d.ts.map +1 -0
- package/dist/middleware/router.js +244 -0
- package/dist/middleware/router.js.map +1 -0
- package/dist/services/auth.d.ts +7 -0
- package/dist/services/auth.d.ts.map +1 -0
- package/dist/services/auth.js +84 -0
- package/dist/services/auth.js.map +1 -0
- package/dist/types/auth.d.ts +234 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +2 -0
- package/dist/types/auth.js.map +1 -0
- 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` |
|