sentri 1.0.4 → 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 +266 -862
- package/dist/cli.js +79 -26
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +13 -7
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -1
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/authorize.js +3 -3
- package/dist/middleware/authorize.js.map +1 -1
- package/dist/middleware/permit.d.ts +8 -8
- package/dist/middleware/permit.d.ts.map +1 -1
- package/dist/middleware/permit.js +10 -10
- package/dist/middleware/permit.js.map +1 -1
- package/dist/middleware/protect.js +5 -5
- package/dist/middleware/protect.js.map +1 -1
- package/dist/middleware/router.d.ts.map +1 -1
- package/dist/middleware/router.js +103 -115
- package/dist/middleware/router.js.map +1 -1
- package/dist/services/auth.d.ts +3 -2
- package/dist/services/auth.d.ts.map +1 -1
- package/dist/services/auth.js +15 -5
- package/dist/services/auth.js.map +1 -1
- package/dist/types/auth.d.ts +32 -2
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/types/auth.js +20 -1
- package/dist/types/auth.js.map +1 -1
- package/package.json +1 -1
- package/templates/drizzle/adapter.ts +160 -0
- package/templates/drizzle/auth.ts +27 -0
- package/templates/drizzle/schema.ts +47 -0
- package/templates/prisma/adapter.ts +128 -0
- package/templates/prisma/auth.ts +30 -0
- /package/templates/{schema.prisma → prisma/schema.prisma} +0 -0
package/README.md
CHANGED
|
@@ -1,1044 +1,448 @@
|
|
|
1
1
|
# sentri
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
##
|
|
22
|
+
## Installation
|
|
37
23
|
|
|
38
24
|
```bash
|
|
39
|
-
npm install sentri
|
|
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
|
-
##
|
|
32
|
+
## Quick Start
|
|
46
33
|
|
|
47
|
-
|
|
34
|
+
### 1. Generate templates
|
|
48
35
|
|
|
49
|
-
|
|
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
|
-
|
|
44
|
+
This creates:
|
|
59
45
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
###
|
|
57
|
+
### 2. Mount the router
|
|
68
58
|
|
|
69
|
-
|
|
59
|
+
```typescript
|
|
60
|
+
import express from 'express';
|
|
61
|
+
import { auth } from './lib/sentri/auth.js';
|
|
70
62
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
##
|
|
72
|
+
## CLI
|
|
79
73
|
|
|
80
|
-
###
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
### Langkah 3: Siapkan tabel database
|
|
83
|
+
**What gets created:**
|
|
105
84
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
generator client {
|
|
112
|
-
provider = "prisma-client-js"
|
|
113
|
-
}
|
|
93
|
+
---
|
|
114
94
|
|
|
115
|
-
|
|
116
|
-
provider = "postgresql"
|
|
117
|
-
url = env("DATABASE_URL")
|
|
118
|
-
}
|
|
95
|
+
## Configuration
|
|
119
96
|
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
}
|
|
121
|
+
`accessExpiresIn` and `refreshExpiresIn` accept duration strings (`'15m'`, `'1h'`, `'7d'`, `'30d'`) or seconds as a number.
|
|
144
122
|
|
|
145
|
-
|
|
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
|
-
|
|
155
|
-
npx prisma db push # atau prisma migrate dev
|
|
156
|
-
npx prisma generate
|
|
157
|
-
```
|
|
125
|
+
---
|
|
158
126
|
|
|
159
|
-
|
|
127
|
+
## Adapter Interface
|
|
160
128
|
|
|
161
|
-
|
|
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
|
-
|
|
134
|
+
const adapter: AuthAdapter = {
|
|
184
135
|
user: {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
287
|
-
import '
|
|
288
|
-
import
|
|
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
|
|
161
|
+
const prisma = new PrismaClient();
|
|
162
|
+
export const adapter = createAdapter(prisma);
|
|
295
163
|
|
|
296
|
-
//
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
175
|
+
## Pre-built Router
|
|
374
176
|
|
|
375
|
-
|
|
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
|
-
```
|
|
386
|
-
// 201 Created
|
|
179
|
+
```typescript
|
|
387
180
|
{
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
399
|
-
// 409 Conflict — identifier sudah terdaftar
|
|
400
|
-
{ "code": "USER_ALREADY_EXISTS", "message": "User already exists" }
|
|
401
|
-
```
|
|
188
|
+
### Endpoints
|
|
402
189
|
|
|
403
|
-
|
|
404
|
-
// 400 Bad Request — role tidak ada di validRoles
|
|
405
|
-
{ "code": "INVALID_ROLE", "message": "Invalid roles: superuser" }
|
|
406
|
-
```
|
|
190
|
+
#### `POST /signup`
|
|
407
191
|
|
|
408
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
+
---
|
|
435
203
|
|
|
436
|
-
|
|
204
|
+
#### `POST /login`
|
|
437
205
|
|
|
438
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
// 403 Forbidden — tidak punya role yang dibutuhkan
|
|
460
|
-
{ "code": "FORBIDDEN", "message": "Requires one of roles: admin" }
|
|
461
|
-
```
|
|
216
|
+
---
|
|
462
217
|
|
|
463
|
-
|
|
218
|
+
#### `POST /refresh`
|
|
464
219
|
|
|
465
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
+
---
|
|
487
231
|
|
|
488
|
-
|
|
232
|
+
#### `POST /logout`
|
|
489
233
|
|
|
490
|
-
|
|
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
|
-
```
|
|
497
|
-
|
|
498
|
-
|
|
236
|
+
```
|
|
237
|
+
Cookie: refresh_token=<token>
|
|
238
|
+
Returns: null
|
|
239
|
+
Status: 200
|
|
499
240
|
```
|
|
500
241
|
|
|
501
|
-
|
|
242
|
+
Safe to call even if the cookie is missing or the token is already expired.
|
|
502
243
|
|
|
503
|
-
|
|
244
|
+
---
|
|
504
245
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
```
|
|
246
|
+
#### `POST /logout-all`
|
|
247
|
+
|
|
248
|
+
Invalidate all sessions for the authenticated user (logout from every device).
|
|
509
249
|
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
|
|
250
|
+
```
|
|
251
|
+
Headers: Authorization: Bearer <accessToken>
|
|
252
|
+
Returns: null
|
|
253
|
+
Status: 200
|
|
513
254
|
```
|
|
514
255
|
|
|
515
|
-
|
|
256
|
+
---
|
|
516
257
|
|
|
517
|
-
|
|
258
|
+
#### `GET /me`
|
|
518
259
|
|
|
519
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
-
|
|
270
|
+
#### `POST /users/:userId/roles`
|
|
541
271
|
|
|
542
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Middleware
|
|
551
284
|
|
|
552
285
|
### `auth.protect()`
|
|
553
286
|
|
|
554
|
-
|
|
287
|
+
Verifies the `Authorization: Bearer <token>` header and injects `req.user` into the request.
|
|
555
288
|
|
|
556
289
|
```typescript
|
|
557
|
-
router.get('/
|
|
558
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
302
|
+
router.delete(
|
|
303
|
+
'/posts/:id',
|
|
304
|
+
auth.protect(),
|
|
305
|
+
auth.authorize('admin'),
|
|
306
|
+
handler,
|
|
307
|
+
);
|
|
577
308
|
```
|
|
578
309
|
|
|
579
|
-
|
|
310
|
+
---
|
|
580
311
|
|
|
581
|
-
|
|
312
|
+
### `auth.permit(check | options)`
|
|
582
313
|
|
|
583
|
-
**
|
|
314
|
+
Resource-level permission check. Must be used **after** `auth.protect()`. Return `true` to allow, `false` to deny.
|
|
584
315
|
|
|
585
316
|
```typescript
|
|
586
|
-
//
|
|
587
|
-
router.
|
|
317
|
+
// Simple ownership check
|
|
318
|
+
router.put(
|
|
319
|
+
'/users/:id',
|
|
588
320
|
auth.protect(),
|
|
589
|
-
auth.permit(
|
|
590
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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 (
|
|
625
|
-
const
|
|
626
|
-
return
|
|
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
|
-
|
|
336
|
+
handler,
|
|
630
337
|
);
|
|
631
338
|
```
|
|
632
339
|
|
|
633
340
|
---
|
|
634
341
|
|
|
635
|
-
##
|
|
342
|
+
## Programmatic API
|
|
636
343
|
|
|
637
|
-
|
|
344
|
+
All methods are available on the auth client for use outside the built-in router.
|
|
638
345
|
|
|
639
346
|
```typescript
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
//
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
+
All errors thrown by the library are instances of `AuthError` with a machine-readable `code`:
|
|
933
416
|
|
|
934
|
-
|
|
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
|
|
435
|
+
app.use((err, req, res, next) => {
|
|
940
436
|
if (err instanceof AuthError) {
|
|
941
|
-
const
|
|
942
|
-
UNAUTHORIZED
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
-
|
|
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` |
|