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