@flink-app/jwt-auth-plugin 0.12.1-alpha.7 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/BcryptUtils.js +2 -3
- package/dist/FlinkJwtAuthPlugin.d.ts +68 -3
- package/dist/FlinkJwtAuthPlugin.js +55 -23
- package/dist/PermissionValidator.js +1 -2
- package/package.json +31 -34
- package/readme.md +1301 -18
- package/spec/FlinkJwtAuthPlugin.spec.ts +692 -5
- package/src/FlinkJwtAuthPlugin.ts +213 -130
- package/tsconfig.json +1 -1
package/readme.md
CHANGED
|
@@ -1,33 +1,1316 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JWT Authentication Plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Flink authentication plugin that provides JWT (JSON Web Token) based authentication with role-based permissions, password hashing, and token management.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
registered routes and schemas.
|
|
5
|
+
## Features
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
- JWT token generation and validation
|
|
8
|
+
- Password hashing using bcrypt
|
|
9
|
+
- Role-based access control with permissions
|
|
10
|
+
- Configurable password policies
|
|
11
|
+
- Token expiration support
|
|
12
|
+
- Bearer token authentication
|
|
13
|
+
- Custom token extraction (query params, cookies, custom headers)
|
|
14
|
+
- Dynamic permission checking (database-backed permissions)
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
## Installation
|
|
11
17
|
|
|
18
|
+
Install the plugin in your Flink app project:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @flink-app/jwt-auth-plugin
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Basic Setup
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
28
|
+
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
|
|
29
|
+
import { Ctx } from "./Ctx";
|
|
30
|
+
|
|
31
|
+
function start() {
|
|
32
|
+
const app = new FlinkApp<Ctx>({
|
|
33
|
+
name: "My Flink App",
|
|
34
|
+
auth: jwtAuthPlugin({
|
|
35
|
+
secret: process.env.JWT_SECRET || "your-secret-key",
|
|
36
|
+
getUser: async (tokenData) => {
|
|
37
|
+
// Retrieve user from database using token data
|
|
38
|
+
const user = await ctx.repos.userRepo.findById(tokenData.userId);
|
|
39
|
+
return {
|
|
40
|
+
id: user._id,
|
|
41
|
+
username: user.username,
|
|
42
|
+
roles: user.roles,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
rolePermissions: {
|
|
46
|
+
admin: ["read", "write", "delete", "manage_users"],
|
|
47
|
+
user: ["read", "write"],
|
|
48
|
+
guest: ["read"],
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
db: {
|
|
52
|
+
uri: "mongodb://localhost:27017/my-app",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.start();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
start();
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Configuration Options
|
|
63
|
+
|
|
64
|
+
### `JwtAuthPluginOptions`
|
|
65
|
+
|
|
66
|
+
| Option | Type | Required | Default | Description |
|
|
67
|
+
|--------|------|----------|---------|-------------|
|
|
68
|
+
| `secret` | `string` | Yes | - | Secret key used to sign and verify JWT tokens. Keep this secure! |
|
|
69
|
+
| `getUser` | `(tokenData: any, req: FlinkRequest) => Promise<FlinkAuthUser>` | Yes | - | Async function that retrieves user data from token payload. Has access to the full request object for context (headers, path, etc.) |
|
|
70
|
+
| `rolePermissions` | `{ [role: string]: string[] }` | Yes | - | Maps roles to their allowed permissions |
|
|
71
|
+
| `algo` | `jwtSimple.TAlgorithm` | No | `"HS256"` | JWT signing algorithm |
|
|
72
|
+
| `passwordPolicy` | `RegExp` | No | `/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/` | Regex to validate password strength |
|
|
73
|
+
| `tokenTTL` | `number` | No | `1000 * 60 * 60 * 24 * 365 * 100` (100 years) | Token time-to-live in milliseconds |
|
|
74
|
+
| `tokenExtractor` | `(req: FlinkRequest) => string \| null \| undefined` | No | - | Custom token extraction function. Return `string` for token, `null` for no token, `undefined` to fallback to Bearer |
|
|
75
|
+
| `checkPermissions` | `(user: FlinkAuthUser, routePermissions: string[]) => Promise<boolean> \| boolean` | No | - | Custom permission validator for dynamic permissions from database. Replaces static `rolePermissions` when provided |
|
|
76
|
+
| `useDynamicRoles` | `boolean` | No | `false` | When `true`, uses roles from the user object returned by `getUser` instead of roles from the token. Useful for multi-tenant scenarios where user roles vary by organization |
|
|
77
|
+
|
|
78
|
+
### Default Password Policy
|
|
79
|
+
|
|
80
|
+
The default password policy requires:
|
|
81
|
+
- Minimum 8 characters
|
|
82
|
+
- At least one letter (A-Z or a-z)
|
|
83
|
+
- At least one number (0-9)
|
|
84
|
+
|
|
85
|
+
You can customize this by providing your own regex:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
jwtAuthPlugin({
|
|
89
|
+
secret: "your-secret",
|
|
90
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
91
|
+
rolePermissions: { /* ... */ },
|
|
92
|
+
passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{12,}$/,
|
|
93
|
+
// Requires: 12+ chars, 1 letter, 1 number, 1 special character
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Custom Token Extraction
|
|
98
|
+
|
|
99
|
+
By default, the plugin extracts JWT tokens from the `Authorization` header as Bearer tokens:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
Authorization: Bearer <token>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
However, you can customize token extraction using the `tokenExtractor` option. This is useful for:
|
|
106
|
+
- Mobile apps that pass tokens in query parameters
|
|
107
|
+
- Cookie-based authentication for web routes
|
|
108
|
+
- Custom header schemes for specific endpoints
|
|
109
|
+
- Different auth methods for different route patterns
|
|
110
|
+
|
|
111
|
+
### Token Extractor Return Values
|
|
112
|
+
|
|
113
|
+
The `tokenExtractor` callback supports three return values:
|
|
114
|
+
|
|
115
|
+
- **`string`**: Token found, use this token for authentication
|
|
116
|
+
- **`null`**: No token found, authentication should fail (no fallback to default Bearer)
|
|
117
|
+
- **`undefined`**: Skip custom extraction, use default Bearer token extraction
|
|
118
|
+
|
|
119
|
+
### Example: Query Parameter for Public API Routes
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
jwtAuthPlugin({
|
|
123
|
+
secret: process.env.JWT_SECRET!,
|
|
124
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
125
|
+
rolePermissions: { /* ... */ },
|
|
126
|
+
tokenExtractor: (req) => {
|
|
127
|
+
// Allow query param tokens only for public API routes
|
|
128
|
+
if (req.path?.startsWith('/api/public/') && req.method === 'GET') {
|
|
129
|
+
return req.query?.token as string || null;
|
|
130
|
+
}
|
|
131
|
+
// All other routes use default Bearer token
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Usage:**
|
|
138
|
+
```
|
|
139
|
+
GET /api/public/data?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Example: Cookie-Based Auth for Web, Bearer for API
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
jwtAuthPlugin({
|
|
146
|
+
secret: process.env.JWT_SECRET!,
|
|
147
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
148
|
+
rolePermissions: { /* ... */ },
|
|
149
|
+
tokenExtractor: (req) => {
|
|
150
|
+
// Web routes use session cookie
|
|
151
|
+
if (req.path?.startsWith('/web/')) {
|
|
152
|
+
return req.cookies?.session_token || null;
|
|
153
|
+
}
|
|
154
|
+
// API routes use Bearer token (default)
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Example: Custom Header for Webhooks
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
jwtAuthPlugin({
|
|
164
|
+
secret: process.env.JWT_SECRET!,
|
|
165
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
166
|
+
rolePermissions: { /* ... */ },
|
|
167
|
+
tokenExtractor: (req) => {
|
|
168
|
+
// Webhook endpoints use custom header
|
|
169
|
+
if (req.path?.startsWith('/webhooks/')) {
|
|
170
|
+
return req.headers['x-webhook-signature'] as string || null;
|
|
171
|
+
}
|
|
172
|
+
// Other routes use Bearer token
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Example: Method-Based Extraction
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
jwtAuthPlugin({
|
|
182
|
+
secret: process.env.JWT_SECRET!,
|
|
183
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
184
|
+
rolePermissions: { /* ... */ },
|
|
185
|
+
tokenExtractor: (req) => {
|
|
186
|
+
// Special handling for PATCH requests
|
|
187
|
+
if (req.method === 'PATCH') {
|
|
188
|
+
return req.headers['x-patch-token'] as string || null;
|
|
189
|
+
}
|
|
190
|
+
// All other methods use Bearer
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Example: Multiple Fallbacks
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
jwtAuthPlugin({
|
|
200
|
+
secret: process.env.JWT_SECRET!,
|
|
201
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
202
|
+
rolePermissions: { /* ... */ },
|
|
203
|
+
tokenExtractor: (req) => {
|
|
204
|
+
// Try cookie first for browser requests
|
|
205
|
+
if (req.headers['user-agent']?.includes('Mozilla')) {
|
|
206
|
+
const cookieToken = req.cookies?.auth_token;
|
|
207
|
+
if (cookieToken) return cookieToken;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Try query param for mobile apps
|
|
211
|
+
if (req.query?.token) {
|
|
212
|
+
return req.query.token as string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Fall back to default Bearer token extraction
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Important Notes
|
|
222
|
+
|
|
223
|
+
- When `tokenExtractor` returns `undefined`, the plugin falls back to extracting from `Authorization: Bearer <token>`
|
|
224
|
+
- When it returns `null`, authentication fails immediately (useful to enforce specific auth methods for certain routes)
|
|
225
|
+
- When it returns a `string`, that token is validated using the same JWT verification logic
|
|
226
|
+
- The callback has access to `req.path`, `req.method`, `req.headers`, `req.query`, `req.cookies`, etc.
|
|
227
|
+
|
|
228
|
+
## Dynamic Permissions with Database
|
|
229
|
+
|
|
230
|
+
By default, the plugin uses static `rolePermissions` defined at configuration time. However, you can implement dynamic permissions that are fetched from the database on each request using the `checkPermissions` callback.
|
|
231
|
+
|
|
232
|
+
### When to Use Dynamic Permissions
|
|
233
|
+
|
|
234
|
+
Use `checkPermissions` when:
|
|
235
|
+
- Permissions are stored in the database per user or per role
|
|
236
|
+
- Permissions can change without restarting the application
|
|
237
|
+
- Different organizations/tenants have different permission sets
|
|
238
|
+
- You need fine-grained, user-specific permissions
|
|
239
|
+
|
|
240
|
+
### How It Works
|
|
241
|
+
|
|
242
|
+
When you provide `checkPermissions`:
|
|
243
|
+
1. Token is extracted and decoded (same as before)
|
|
244
|
+
2. Static `rolePermissions` check is **skipped**
|
|
245
|
+
3. `getUser` is called - this is where you fetch permissions from DB
|
|
246
|
+
4. `checkPermissions` is called with the user object and required route permissions
|
|
247
|
+
5. If `checkPermissions` returns `true`, authentication succeeds
|
|
248
|
+
|
|
249
|
+
### Basic Example
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
jwtAuthPlugin({
|
|
253
|
+
secret: process.env.JWT_SECRET!,
|
|
254
|
+
|
|
255
|
+
getUser: async (tokenData) => {
|
|
256
|
+
// Fetch user from database
|
|
257
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
258
|
+
|
|
259
|
+
// Fetch user's permissions from database
|
|
260
|
+
const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
id: user._id,
|
|
264
|
+
username: user.username,
|
|
265
|
+
roles: user.roles,
|
|
266
|
+
permissions, // Attach permissions to user object
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
// rolePermissions can be empty when using dynamic permissions
|
|
271
|
+
rolePermissions: {},
|
|
272
|
+
|
|
273
|
+
// Custom permission checker
|
|
274
|
+
checkPermissions: async (user, routePermissions) => {
|
|
275
|
+
// User must have ALL required permissions
|
|
276
|
+
return routePermissions.every(perm =>
|
|
277
|
+
user.permissions?.includes(perm)
|
|
278
|
+
);
|
|
279
|
+
},
|
|
280
|
+
})
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Multi-Tenant Example
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
jwtAuthPlugin({
|
|
287
|
+
secret: process.env.JWT_SECRET!,
|
|
288
|
+
|
|
289
|
+
getUser: async (tokenData) => {
|
|
290
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
291
|
+
|
|
292
|
+
// Fetch permissions based on user's organization
|
|
293
|
+
const permissions = await ctx.repos.permissionRepo.getOrgPermissions(
|
|
294
|
+
user._id,
|
|
295
|
+
user.organizationId
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
id: user._id,
|
|
300
|
+
username: user.username,
|
|
301
|
+
organizationId: user.organizationId,
|
|
302
|
+
permissions,
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
rolePermissions: {},
|
|
307
|
+
|
|
308
|
+
checkPermissions: async (user, routePermissions) => {
|
|
309
|
+
return routePermissions.every(perm =>
|
|
310
|
+
user.permissions?.includes(perm)
|
|
311
|
+
);
|
|
312
|
+
},
|
|
313
|
+
})
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Hybrid: Role-Based + User-Specific Permissions
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
jwtAuthPlugin({
|
|
320
|
+
secret: process.env.JWT_SECRET!,
|
|
321
|
+
|
|
322
|
+
getUser: async (tokenData) => {
|
|
323
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
324
|
+
|
|
325
|
+
// Get base permissions from roles
|
|
326
|
+
const rolePerms = await ctx.repos.roleRepo.getRolePermissions(user.roles);
|
|
327
|
+
|
|
328
|
+
// Get user-specific permission overrides
|
|
329
|
+
const userPerms = await ctx.repos.permissionRepo.getUserPermissions(user._id);
|
|
330
|
+
|
|
331
|
+
// Combine both
|
|
332
|
+
const allPermissions = [...new Set([...rolePerms, ...userPerms])];
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
id: user._id,
|
|
336
|
+
username: user.username,
|
|
337
|
+
roles: user.roles,
|
|
338
|
+
permissions: allPermissions,
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
rolePermissions: {},
|
|
343
|
+
|
|
344
|
+
checkPermissions: async (user, routePermissions) => {
|
|
345
|
+
return routePermissions.every(perm =>
|
|
346
|
+
user.permissions?.includes(perm)
|
|
347
|
+
);
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### Permission with Wildcards
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
checkPermissions: async (user, routePermissions) => {
|
|
356
|
+
// Support wildcard permissions
|
|
357
|
+
if (user.permissions?.includes("*")) {
|
|
358
|
+
return true; // User has all permissions
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Check specific permissions
|
|
362
|
+
return routePermissions.every(perm =>
|
|
363
|
+
user.permissions?.includes(perm)
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Permission with OR Logic
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
checkPermissions: async (user, routePermissions) => {
|
|
372
|
+
// User needs ANY of the route permissions (OR logic)
|
|
373
|
+
return routePermissions.some(perm =>
|
|
374
|
+
user.permissions?.includes(perm)
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Caching Permissions for Performance
|
|
380
|
+
|
|
381
|
+
To reduce database load, you can cache permissions:
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
const permissionCache = new Map();
|
|
385
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
386
|
+
|
|
387
|
+
jwtAuthPlugin({
|
|
388
|
+
secret: process.env.JWT_SECRET!,
|
|
389
|
+
|
|
390
|
+
getUser: async (tokenData) => {
|
|
391
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
392
|
+
|
|
393
|
+
// Check cache first
|
|
394
|
+
const cacheKey = `perms:${user._id}`;
|
|
395
|
+
const cached = permissionCache.get(cacheKey);
|
|
396
|
+
|
|
397
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
398
|
+
return {
|
|
399
|
+
id: user._id,
|
|
400
|
+
username: user.username,
|
|
401
|
+
permissions: cached.permissions,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Fetch from DB
|
|
406
|
+
const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
|
|
407
|
+
|
|
408
|
+
// Cache it
|
|
409
|
+
permissionCache.set(cacheKey, {
|
|
410
|
+
permissions,
|
|
411
|
+
timestamp: Date.now(),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
id: user._id,
|
|
416
|
+
username: user.username,
|
|
417
|
+
permissions,
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
rolePermissions: {},
|
|
422
|
+
checkPermissions: async (user, routePermissions) => {
|
|
423
|
+
return routePermissions.every(perm => user.permissions?.includes(perm));
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
### Dynamic Permissions Notes
|
|
429
|
+
|
|
430
|
+
- **Backward Compatible**: If you don't provide `checkPermissions`, static `rolePermissions` are used (existing behavior)
|
|
431
|
+
- **Performance**: `checkPermissions` is called on every authenticated request, so ensure `getUser` is optimized (consider caching)
|
|
432
|
+
- **User Object**: The `user` parameter in `checkPermissions` is the exact object returned from `getUser`
|
|
433
|
+
- **Public Routes**: If a route has no permissions (`[]`), `checkPermissions` is NOT called
|
|
434
|
+
- **Sync or Async**: `checkPermissions` can return `Promise<boolean>` or `boolean`
|
|
435
|
+
|
|
436
|
+
### Troubleshooting Dynamic Permissions
|
|
437
|
+
|
|
438
|
+
**Issue**: Too many database queries
|
|
439
|
+
|
|
440
|
+
**Solution**: Implement permission caching in `getUser` or use an in-memory cache like Redis
|
|
441
|
+
|
|
442
|
+
**Issue**: Permissions not updating after database change
|
|
443
|
+
|
|
444
|
+
**Solution**: Clear permission cache or reduce cache TTL
|
|
445
|
+
|
|
446
|
+
## Multi-Tenant & Dynamic Roles
|
|
447
|
+
|
|
448
|
+
The JWT auth plugin supports multi-tenant scenarios where users have different roles depending on the organization or context they're accessing. This is achieved through the `useDynamicRoles` option combined with the `req` parameter in `getUser`.
|
|
449
|
+
|
|
450
|
+
### When to Use Dynamic Roles
|
|
451
|
+
|
|
452
|
+
Use `useDynamicRoles: true` when:
|
|
453
|
+
- Users belong to multiple organizations with different roles in each
|
|
454
|
+
- User roles are determined by request context (headers, subdomain, path)
|
|
455
|
+
- Roles need to be fetched from the database based on the current context
|
|
456
|
+
- The same user token should grant different permissions in different contexts
|
|
457
|
+
|
|
458
|
+
### How It Works
|
|
459
|
+
|
|
460
|
+
1. **Default behavior** (`useDynamicRoles: false`):
|
|
461
|
+
- Roles from the JWT token are used for permission checking
|
|
462
|
+
- `getUser` can modify the user object, but token roles are what matter for permissions
|
|
463
|
+
|
|
464
|
+
2. **Dynamic roles** (`useDynamicRoles: true`):
|
|
465
|
+
- Permission checking uses roles from the user object returned by `getUser`
|
|
466
|
+
- Token roles are ignored for permission checks
|
|
467
|
+
- `getUser` can fetch organization-specific roles from the database
|
|
468
|
+
|
|
469
|
+
### Basic Multi-Tenant Example
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
jwtAuthPlugin({
|
|
473
|
+
secret: process.env.JWT_SECRET!,
|
|
474
|
+
useDynamicRoles: true,
|
|
475
|
+
|
|
476
|
+
getUser: async (tokenData, req) => {
|
|
477
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
478
|
+
|
|
479
|
+
// Get organization from request header
|
|
480
|
+
const orgId = req.headers['x-organization-id'] as string;
|
|
481
|
+
|
|
482
|
+
// Fetch user's role in this specific organization
|
|
483
|
+
const membership = await ctx.repos.orgMemberRepo.findOne({
|
|
484
|
+
userId: user._id,
|
|
485
|
+
organizationId: orgId
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
id: user._id,
|
|
490
|
+
username: user.username,
|
|
491
|
+
organizationId: orgId,
|
|
492
|
+
roles: [membership.role], // Org-specific role
|
|
493
|
+
};
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
rolePermissions: {
|
|
497
|
+
admin: ["read", "write", "delete", "manage_users"],
|
|
498
|
+
user: ["read", "write"],
|
|
499
|
+
guest: ["read"],
|
|
500
|
+
},
|
|
501
|
+
})
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Usage:**
|
|
505
|
+
```bash
|
|
506
|
+
# User is admin in org1
|
|
507
|
+
curl -H "Authorization: Bearer <token>" \
|
|
508
|
+
-H "X-Organization-ID: org1" \
|
|
509
|
+
https://api.example.com/users
|
|
510
|
+
# ✓ Has admin permissions in org1
|
|
511
|
+
|
|
512
|
+
# Same user is regular user in org2
|
|
513
|
+
curl -H "Authorization: Bearer <token>" \
|
|
514
|
+
-H "X-Organization-ID: org2" \
|
|
515
|
+
https://api.example.com/users
|
|
516
|
+
# ✓ Has user permissions in org2 (cannot manage users)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Subdomain-Based Organizations
|
|
520
|
+
|
|
521
|
+
```typescript
|
|
522
|
+
jwtAuthPlugin({
|
|
523
|
+
secret: process.env.JWT_SECRET!,
|
|
524
|
+
useDynamicRoles: true,
|
|
525
|
+
|
|
526
|
+
getUser: async (tokenData, req) => {
|
|
527
|
+
// Extract org from subdomain (acme.yourapp.com -> "acme")
|
|
528
|
+
const host = req.headers.host || '';
|
|
529
|
+
const orgSubdomain = host.split('.')[0];
|
|
530
|
+
|
|
531
|
+
const membership = await ctx.repos.orgMemberRepo.findOne({
|
|
532
|
+
userId: tokenData.userId,
|
|
533
|
+
organizationSlug: orgSubdomain
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (!membership) {
|
|
537
|
+
return null; // User not member of this org
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
id: tokenData.userId,
|
|
542
|
+
username: tokenData.username,
|
|
543
|
+
organizationSlug: orgSubdomain,
|
|
544
|
+
roles: [membership.role],
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
rolePermissions: {
|
|
549
|
+
owner: ["*"], // All permissions
|
|
550
|
+
admin: ["read", "write", "delete", "manage_users"],
|
|
551
|
+
member: ["read", "write"],
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### Path-Based Organization Context
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
// Routes like: /orgs/:orgId/projects
|
|
560
|
+
jwtAuthPlugin({
|
|
561
|
+
secret: process.env.JWT_SECRET!,
|
|
562
|
+
useDynamicRoles: true,
|
|
563
|
+
|
|
564
|
+
getUser: async (tokenData, req) => {
|
|
565
|
+
// Extract org from path: /orgs/org123/projects
|
|
566
|
+
const pathMatch = req.path?.match(/^\/orgs\/([^\/]+)/);
|
|
567
|
+
const orgId = pathMatch?.[1];
|
|
568
|
+
|
|
569
|
+
if (!orgId) {
|
|
570
|
+
// Not an org-specific route, use default role
|
|
571
|
+
return {
|
|
572
|
+
id: tokenData.userId,
|
|
573
|
+
username: tokenData.username,
|
|
574
|
+
roles: ["user"],
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const membership = await ctx.repos.orgMemberRepo.findOne({
|
|
579
|
+
userId: tokenData.userId,
|
|
580
|
+
organizationId: orgId
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
id: tokenData.userId,
|
|
585
|
+
username: tokenData.username,
|
|
586
|
+
organizationId: orgId,
|
|
587
|
+
roles: [membership.role],
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
rolePermissions: {
|
|
592
|
+
admin: ["read", "write", "delete"],
|
|
593
|
+
member: ["read", "write"],
|
|
594
|
+
viewer: ["read"],
|
|
595
|
+
},
|
|
596
|
+
})
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Combining Multiple Role Sources
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
jwtAuthPlugin({
|
|
603
|
+
secret: process.env.JWT_SECRET!,
|
|
604
|
+
useDynamicRoles: true,
|
|
605
|
+
|
|
606
|
+
getUser: async (tokenData, req) => {
|
|
607
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
608
|
+
const orgId = req.headers['x-organization-id'] as string;
|
|
609
|
+
|
|
610
|
+
// Get org-specific role
|
|
611
|
+
const orgMembership = await ctx.repos.orgMemberRepo.findOne({
|
|
612
|
+
userId: user._id,
|
|
613
|
+
organizationId: orgId
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Get global role from user
|
|
617
|
+
const globalRole = user.globalRole; // e.g., "super_admin"
|
|
618
|
+
|
|
619
|
+
// Combine roles (super admins have all permissions everywhere)
|
|
620
|
+
const roles = globalRole === 'super_admin'
|
|
621
|
+
? ['super_admin']
|
|
622
|
+
: [orgMembership.role];
|
|
623
|
+
|
|
624
|
+
return {
|
|
625
|
+
id: user._id,
|
|
626
|
+
username: user.username,
|
|
627
|
+
organizationId: orgId,
|
|
628
|
+
globalRole,
|
|
629
|
+
roles,
|
|
630
|
+
};
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
rolePermissions: {
|
|
634
|
+
super_admin: ["*"], // Global super admins
|
|
635
|
+
org_admin: ["read", "write", "delete", "manage_members"],
|
|
636
|
+
org_member: ["read", "write"],
|
|
637
|
+
org_guest: ["read"],
|
|
638
|
+
},
|
|
639
|
+
})
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Performance Considerations
|
|
643
|
+
|
|
644
|
+
Since `getUser` is called on every authenticated request, consider caching:
|
|
645
|
+
|
|
646
|
+
```typescript
|
|
647
|
+
const roleCache = new Map();
|
|
648
|
+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
649
|
+
|
|
650
|
+
jwtAuthPlugin({
|
|
651
|
+
secret: process.env.JWT_SECRET!,
|
|
652
|
+
useDynamicRoles: true,
|
|
653
|
+
|
|
654
|
+
getUser: async (tokenData, req) => {
|
|
655
|
+
const orgId = req.headers['x-organization-id'] as string;
|
|
656
|
+
const cacheKey = `${tokenData.userId}:${orgId}`;
|
|
657
|
+
|
|
658
|
+
// Check cache
|
|
659
|
+
const cached = roleCache.get(cacheKey);
|
|
660
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
661
|
+
return cached.user;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Fetch from DB
|
|
665
|
+
const membership = await ctx.repos.orgMemberRepo.findOne({
|
|
666
|
+
userId: tokenData.userId,
|
|
667
|
+
organizationId: orgId
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const user = {
|
|
671
|
+
id: tokenData.userId,
|
|
672
|
+
username: tokenData.username,
|
|
673
|
+
organizationId: orgId,
|
|
674
|
+
roles: [membership.role],
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
// Cache it
|
|
678
|
+
roleCache.set(cacheKey, {
|
|
679
|
+
user,
|
|
680
|
+
timestamp: Date.now(),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
return user;
|
|
684
|
+
},
|
|
685
|
+
|
|
686
|
+
rolePermissions: {
|
|
687
|
+
admin: ["read", "write", "delete"],
|
|
688
|
+
member: ["read", "write"],
|
|
689
|
+
},
|
|
690
|
+
})
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### Request Context Access
|
|
694
|
+
|
|
695
|
+
The `getUser` callback receives the full request object, giving you access to:
|
|
696
|
+
|
|
697
|
+
```typescript
|
|
698
|
+
getUser: async (tokenData, req) => {
|
|
699
|
+
// Available request properties:
|
|
700
|
+
req.path // "/api/users"
|
|
701
|
+
req.method // "GET", "POST", etc.
|
|
702
|
+
req.headers // { "x-organization-id": "org1", ... }
|
|
703
|
+
req.query // { page: "1", limit: "10" }
|
|
704
|
+
req.body // Request body (if applicable)
|
|
705
|
+
req.params // URL parameters
|
|
706
|
+
req.cookies // Cookies (if cookie-parser middleware is used)
|
|
707
|
+
|
|
708
|
+
// Use any combination to determine context
|
|
709
|
+
const orgId = req.headers['x-organization-id']
|
|
710
|
+
|| req.query.org
|
|
711
|
+
|| req.params.orgId;
|
|
712
|
+
|
|
713
|
+
// Fetch and return user with context-specific roles
|
|
714
|
+
// ...
|
|
715
|
+
}
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
### Migration from Static Roles
|
|
719
|
+
|
|
720
|
+
If you have an existing app with static roles, you can migrate gradually:
|
|
721
|
+
|
|
722
|
+
**Before (static roles):**
|
|
723
|
+
```typescript
|
|
724
|
+
jwtAuthPlugin({
|
|
725
|
+
secret: process.env.JWT_SECRET!,
|
|
726
|
+
getUser: async (tokenData, req) => {
|
|
727
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
728
|
+
return {
|
|
729
|
+
id: user._id,
|
|
730
|
+
username: user.username,
|
|
731
|
+
// Token roles are used
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
rolePermissions: {
|
|
735
|
+
admin: ["read", "write", "delete"],
|
|
736
|
+
user: ["read", "write"],
|
|
737
|
+
},
|
|
738
|
+
})
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
**After (dynamic multi-tenant roles):**
|
|
742
|
+
```typescript
|
|
743
|
+
jwtAuthPlugin({
|
|
744
|
+
secret: process.env.JWT_SECRET!,
|
|
745
|
+
useDynamicRoles: true, // ← Enable dynamic roles
|
|
746
|
+
getUser: async (tokenData, req) => { // ← Now has req parameter
|
|
747
|
+
const user = await ctx.repos.userRepo.getById(tokenData.userId);
|
|
748
|
+
const orgId = req.headers['x-organization-id'];
|
|
749
|
+
|
|
750
|
+
// Fetch org-specific role
|
|
751
|
+
const membership = await ctx.repos.orgMemberRepo.findOne({
|
|
752
|
+
userId: user._id,
|
|
753
|
+
organizationId: orgId
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
id: user._id,
|
|
758
|
+
username: user.username,
|
|
759
|
+
organizationId: orgId,
|
|
760
|
+
roles: [membership.role], // ← Dynamic role
|
|
761
|
+
};
|
|
762
|
+
},
|
|
763
|
+
rolePermissions: {
|
|
764
|
+
admin: ["read", "write", "delete"],
|
|
765
|
+
user: ["read", "write"],
|
|
766
|
+
},
|
|
767
|
+
})
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Important Notes
|
|
771
|
+
|
|
772
|
+
- **Backward Compatible**: Setting `useDynamicRoles: false` (default) maintains existing behavior
|
|
773
|
+
- **Token Still Required**: Dynamic roles don't bypass token validation - the token must be valid
|
|
774
|
+
- **Organization Validation**: Always validate that the user has access to the requested organization
|
|
775
|
+
- **Error Handling**: Return `null` from `getUser` if user doesn't have access to the organization
|
|
776
|
+
- **Cache Invalidation**: Remember to invalidate role cache when user roles change in the database
|
|
777
|
+
|
|
778
|
+
## Context API
|
|
779
|
+
|
|
780
|
+
Once configured, the plugin provides the following methods via the `auth` context:
|
|
781
|
+
|
|
782
|
+
### `createToken(payload: any, roles: string[]): Promise<string>`
|
|
783
|
+
|
|
784
|
+
Creates a JWT token with the provided payload and roles.
|
|
785
|
+
|
|
786
|
+
```typescript
|
|
787
|
+
const token = await ctx.auth.createToken(
|
|
788
|
+
{ userId: user._id, username: user.username },
|
|
789
|
+
["user"]
|
|
790
|
+
);
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
**Parameters:**
|
|
794
|
+
- `payload`: Any data to encode in the token (typically user ID and username)
|
|
795
|
+
- `roles`: Array of role names assigned to the user
|
|
796
|
+
|
|
797
|
+
**Returns:** JWT token string
|
|
798
|
+
|
|
799
|
+
**Example:**
|
|
800
|
+
```typescript
|
|
801
|
+
// In a login handler
|
|
802
|
+
const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
|
|
803
|
+
const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
|
|
804
|
+
|
|
805
|
+
if (!user) {
|
|
806
|
+
return { status: 401, error: { code: "invalid_credentials" } };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const token = await ctx.auth.createToken(
|
|
810
|
+
{ userId: user._id, username: user.username },
|
|
811
|
+
user.roles
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
data: {
|
|
816
|
+
token,
|
|
817
|
+
user: {
|
|
818
|
+
id: user._id,
|
|
819
|
+
username: user.username,
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
};
|
|
823
|
+
};
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
### `createPasswordHashAndSalt(password: string): Promise<{ hash: string; salt: string } | null>`
|
|
827
|
+
|
|
828
|
+
Generates a secure password hash and salt using bcrypt.
|
|
829
|
+
|
|
830
|
+
```typescript
|
|
831
|
+
const result = await ctx.auth.createPasswordHashAndSalt("mypassword123");
|
|
832
|
+
if (result) {
|
|
833
|
+
const { hash, salt } = result;
|
|
834
|
+
// Save hash and salt to database
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**Parameters:**
|
|
839
|
+
- `password`: The plain text password to hash
|
|
840
|
+
|
|
841
|
+
**Returns:**
|
|
842
|
+
- Object with `hash` and `salt` if password meets policy
|
|
843
|
+
- `null` if password doesn't meet the configured password policy
|
|
844
|
+
|
|
845
|
+
**Security Note:** Both hash and salt must be stored in your database to validate passwords later.
|
|
846
|
+
|
|
847
|
+
**Example:**
|
|
848
|
+
```typescript
|
|
849
|
+
// Creating a new user
|
|
850
|
+
const handler: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
|
|
851
|
+
const passwordData = await ctx.auth.createPasswordHashAndSalt(req.body.password);
|
|
852
|
+
|
|
853
|
+
if (!passwordData) {
|
|
854
|
+
return {
|
|
855
|
+
status: 400,
|
|
856
|
+
error: {
|
|
857
|
+
code: "weak_password",
|
|
858
|
+
message: "Password does not meet security requirements",
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const user = await ctx.repos.userRepo.create({
|
|
864
|
+
username: req.body.username,
|
|
865
|
+
password: passwordData.hash,
|
|
866
|
+
salt: passwordData.salt,
|
|
867
|
+
roles: ["user"],
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
return { data: { userId: user._id } };
|
|
871
|
+
};
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
### `validatePassword(password: string, passwordHash: string, salt: string): Promise<boolean>`
|
|
875
|
+
|
|
876
|
+
Validates a password against a stored hash and salt.
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
const isValid = await ctx.auth.validatePassword(
|
|
880
|
+
"mypassword123",
|
|
881
|
+
user.password,
|
|
882
|
+
user.salt
|
|
883
|
+
);
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
**Parameters:**
|
|
887
|
+
- `password`: Plain text password to validate
|
|
888
|
+
- `passwordHash`: Stored password hash from database
|
|
889
|
+
- `salt`: Stored salt from database
|
|
890
|
+
|
|
891
|
+
**Returns:** `true` if password matches, `false` otherwise
|
|
892
|
+
|
|
893
|
+
**Example:**
|
|
894
|
+
```typescript
|
|
895
|
+
// In a login handler
|
|
896
|
+
const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
|
|
897
|
+
const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
|
|
898
|
+
|
|
899
|
+
if (!user) {
|
|
900
|
+
return { status: 401, error: { code: "invalid_credentials" } };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const isValidPassword = await ctx.auth.validatePassword(
|
|
904
|
+
req.body.password,
|
|
905
|
+
user.password,
|
|
906
|
+
user.salt
|
|
907
|
+
);
|
|
908
|
+
|
|
909
|
+
if (!isValidPassword) {
|
|
910
|
+
return { status: 401, error: { code: "invalid_credentials" } };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const token = await ctx.auth.createToken(
|
|
914
|
+
{ userId: user._id, username: user.username },
|
|
915
|
+
user.roles
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
data: {
|
|
920
|
+
token,
|
|
921
|
+
user: { id: user._id, username: user.username },
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
};
|
|
925
|
+
```
|
|
926
|
+
|
|
927
|
+
### `authenticateRequest(req: FlinkRequest, permissions: string | string[]): Promise<boolean>`
|
|
928
|
+
|
|
929
|
+
Automatically called by Flink framework to authenticate requests. You typically don't call this directly.
|
|
930
|
+
|
|
931
|
+
## Role-Based Access Control
|
|
932
|
+
|
|
933
|
+
### Defining Roles and Permissions
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
jwtAuthPlugin({
|
|
937
|
+
secret: "your-secret",
|
|
938
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
939
|
+
rolePermissions: {
|
|
940
|
+
// Admin role can do everything
|
|
941
|
+
admin: ["read", "write", "delete", "manage_users", "view_analytics"],
|
|
942
|
+
|
|
943
|
+
// Regular user has limited permissions
|
|
944
|
+
user: ["read", "write"],
|
|
945
|
+
|
|
946
|
+
// Guest can only read
|
|
947
|
+
guest: ["read"],
|
|
948
|
+
},
|
|
949
|
+
})
|
|
950
|
+
```
|
|
951
|
+
|
|
952
|
+
### Protecting Routes with Permissions
|
|
953
|
+
|
|
954
|
+
Use the `permission` property in your route configuration to restrict access:
|
|
955
|
+
|
|
956
|
+
```typescript
|
|
957
|
+
// Only authenticated users (any role)
|
|
958
|
+
export const Route: RouteProps = {
|
|
959
|
+
path: "/api/profile",
|
|
960
|
+
permission: "read", // Must have "read" permission
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
// Only admins
|
|
964
|
+
export const Route: RouteProps = {
|
|
965
|
+
path: "/api/admin/users",
|
|
966
|
+
permission: "manage_users", // Must have "manage_users" permission
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
// Multiple permissions (user must have at least one)
|
|
970
|
+
export const Route: RouteProps = {
|
|
971
|
+
path: "/api/content",
|
|
972
|
+
permission: ["read", "write"], // Must have either "read" OR "write"
|
|
973
|
+
};
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
### Accessing User in Handlers
|
|
977
|
+
|
|
978
|
+
Once authenticated, the user object is available in `req.user`:
|
|
979
|
+
|
|
980
|
+
```typescript
|
|
981
|
+
const handler: Handler<Ctx, any, any> = async ({ ctx, req }) => {
|
|
982
|
+
// Access authenticated user
|
|
983
|
+
const userId = req.user?.id;
|
|
984
|
+
const username = req.user?.username;
|
|
985
|
+
const roles = req.user?.roles;
|
|
986
|
+
|
|
987
|
+
// Use user data in your logic
|
|
988
|
+
const data = await ctx.repos.dataRepo.findByUserId(userId);
|
|
989
|
+
|
|
990
|
+
return { data };
|
|
991
|
+
};
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
## Making Authenticated Requests
|
|
995
|
+
|
|
996
|
+
Clients must include the JWT token in the `Authorization` header:
|
|
997
|
+
|
|
998
|
+
```
|
|
999
|
+
Authorization: Bearer <your-jwt-token>
|
|
12
1000
|
```
|
|
13
|
-
|
|
1001
|
+
|
|
1002
|
+
### Example with fetch
|
|
1003
|
+
|
|
1004
|
+
```javascript
|
|
1005
|
+
const response = await fetch('https://api.example.com/profile', {
|
|
1006
|
+
method: 'GET',
|
|
1007
|
+
headers: {
|
|
1008
|
+
'Authorization': `Bearer ${token}`,
|
|
1009
|
+
'Content-Type': 'application/json',
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
14
1012
|
```
|
|
15
1013
|
|
|
16
|
-
|
|
1014
|
+
### Example with axios
|
|
17
1015
|
|
|
1016
|
+
```javascript
|
|
1017
|
+
const response = await axios.get('https://api.example.com/profile', {
|
|
1018
|
+
headers: {
|
|
1019
|
+
'Authorization': `Bearer ${token}`,
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
18
1022
|
```
|
|
19
|
-
|
|
1023
|
+
|
|
1024
|
+
## Complete Example
|
|
1025
|
+
|
|
1026
|
+
```typescript
|
|
1027
|
+
// index.ts
|
|
1028
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
1029
|
+
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
|
|
1030
|
+
import { Ctx } from "./Ctx";
|
|
20
1031
|
|
|
21
1032
|
function start() {
|
|
22
|
-
new FlinkApp<
|
|
23
|
-
name: "My
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
1033
|
+
const app = new FlinkApp<Ctx>({
|
|
1034
|
+
name: "My App",
|
|
1035
|
+
auth: jwtAuthPlugin({
|
|
1036
|
+
secret: process.env.JWT_SECRET!,
|
|
1037
|
+
getUser: async (tokenData) => {
|
|
1038
|
+
const user = await app.ctx.repos.userRepo.findById(tokenData.userId);
|
|
1039
|
+
return {
|
|
1040
|
+
id: user._id,
|
|
1041
|
+
username: user.username,
|
|
1042
|
+
roles: user.roles,
|
|
1043
|
+
};
|
|
1044
|
+
},
|
|
1045
|
+
rolePermissions: {
|
|
1046
|
+
admin: ["read", "write", "delete", "manage_users"],
|
|
1047
|
+
user: ["read", "write"],
|
|
1048
|
+
},
|
|
1049
|
+
passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{10,}$/,
|
|
1050
|
+
tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
|
|
1051
|
+
}),
|
|
1052
|
+
db: {
|
|
1053
|
+
uri: process.env.MONGODB_URI!,
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
app.start();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
start();
|
|
1061
|
+
|
|
1062
|
+
// handlers/auth/PostLogin.ts
|
|
1063
|
+
import { Handler, RouteProps } from "@flink-app/flink";
|
|
1064
|
+
import { Ctx } from "../../Ctx";
|
|
1065
|
+
import LoginReq from "../../schemas/LoginReq";
|
|
1066
|
+
import LoginRes from "../../schemas/LoginRes";
|
|
1067
|
+
|
|
1068
|
+
export const Route: RouteProps = {
|
|
1069
|
+
path: "/auth/login",
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
const PostLogin: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
|
|
1073
|
+
const { username, password } = req.body;
|
|
1074
|
+
|
|
1075
|
+
// Find user
|
|
1076
|
+
const user = await ctx.repos.userRepo.findOne({ username });
|
|
1077
|
+
if (!user) {
|
|
1078
|
+
return {
|
|
1079
|
+
status: 401,
|
|
1080
|
+
error: { code: "invalid_credentials", message: "Invalid username or password" },
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Validate password
|
|
1085
|
+
const isValid = await ctx.auth.validatePassword(password, user.password, user.salt);
|
|
1086
|
+
if (!isValid) {
|
|
1087
|
+
return {
|
|
1088
|
+
status: 401,
|
|
1089
|
+
error: { code: "invalid_credentials", message: "Invalid username or password" },
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Create token
|
|
1094
|
+
const token = await ctx.auth.createToken(
|
|
1095
|
+
{ userId: user._id, username: user.username },
|
|
1096
|
+
user.roles
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
return {
|
|
1100
|
+
data: {
|
|
1101
|
+
token,
|
|
1102
|
+
user: {
|
|
1103
|
+
id: user._id,
|
|
1104
|
+
username: user.username,
|
|
1105
|
+
roles: user.roles,
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
export default PostLogin;
|
|
1112
|
+
|
|
1113
|
+
// handlers/users/PostUser.ts
|
|
1114
|
+
import { Handler, RouteProps } from "@flink-app/flink";
|
|
1115
|
+
import { Ctx } from "../../Ctx";
|
|
1116
|
+
import CreateUserReq from "../../schemas/CreateUserReq";
|
|
1117
|
+
import CreateUserRes from "../../schemas/CreateUserRes";
|
|
1118
|
+
|
|
1119
|
+
export const Route: RouteProps = {
|
|
1120
|
+
path: "/users",
|
|
1121
|
+
permission: "manage_users", // Only admins can create users
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
const PostUser: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
|
|
1125
|
+
const { username, password, roles } = req.body;
|
|
1126
|
+
|
|
1127
|
+
// Check if user exists
|
|
1128
|
+
const existingUser = await ctx.repos.userRepo.findOne({ username });
|
|
1129
|
+
if (existingUser) {
|
|
1130
|
+
return {
|
|
1131
|
+
status: 409,
|
|
1132
|
+
error: { code: "user_exists", message: "Username already taken" },
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Hash password
|
|
1137
|
+
const passwordData = await ctx.auth.createPasswordHashAndSalt(password);
|
|
1138
|
+
if (!passwordData) {
|
|
1139
|
+
return {
|
|
1140
|
+
status: 400,
|
|
1141
|
+
error: {
|
|
1142
|
+
code: "weak_password",
|
|
1143
|
+
message: "Password does not meet security requirements",
|
|
1144
|
+
},
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Create user
|
|
1149
|
+
const user = await ctx.repos.userRepo.create({
|
|
1150
|
+
username,
|
|
1151
|
+
password: passwordData.hash,
|
|
1152
|
+
salt: passwordData.salt,
|
|
1153
|
+
roles: roles || ["user"],
|
|
1154
|
+
createdAt: new Date(),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
return {
|
|
1158
|
+
data: {
|
|
1159
|
+
id: user._id,
|
|
1160
|
+
username: user.username,
|
|
1161
|
+
roles: user.roles,
|
|
1162
|
+
},
|
|
1163
|
+
};
|
|
1164
|
+
};
|
|
1165
|
+
|
|
1166
|
+
export default PostUser;
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
## Security Best Practices
|
|
1170
|
+
|
|
1171
|
+
### 1. Secret Key Management
|
|
1172
|
+
|
|
1173
|
+
Never hardcode your JWT secret. Use environment variables:
|
|
1174
|
+
|
|
1175
|
+
```typescript
|
|
1176
|
+
jwtAuthPlugin({
|
|
1177
|
+
secret: process.env.JWT_SECRET!,
|
|
1178
|
+
// ...
|
|
1179
|
+
})
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
Generate a strong secret:
|
|
1183
|
+
```bash
|
|
1184
|
+
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
|
1185
|
+
```
|
|
1186
|
+
|
|
1187
|
+
### 2. Token Expiration
|
|
1188
|
+
|
|
1189
|
+
Set an appropriate TTL for your use case:
|
|
1190
|
+
|
|
1191
|
+
```typescript
|
|
1192
|
+
jwtAuthPlugin({
|
|
1193
|
+
secret: process.env.JWT_SECRET!,
|
|
1194
|
+
tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
|
|
1195
|
+
// ...
|
|
1196
|
+
})
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
### 3. Password Policies
|
|
1200
|
+
|
|
1201
|
+
Enforce strong password requirements:
|
|
1202
|
+
|
|
1203
|
+
```typescript
|
|
1204
|
+
jwtAuthPlugin({
|
|
1205
|
+
secret: process.env.JWT_SECRET!,
|
|
1206
|
+
// Require: 12+ chars, uppercase, lowercase, number, special char
|
|
1207
|
+
passwordPolicy: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,}$/,
|
|
1208
|
+
// ...
|
|
1209
|
+
})
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
### 4. HTTPS Only
|
|
1213
|
+
|
|
1214
|
+
Always use HTTPS in production to prevent token interception.
|
|
1215
|
+
|
|
1216
|
+
### 5. Token Storage (Client-Side)
|
|
1217
|
+
|
|
1218
|
+
- Avoid `localStorage` (vulnerable to XSS)
|
|
1219
|
+
- Prefer `httpOnly` cookies or secure session storage
|
|
1220
|
+
- Implement token refresh mechanisms for long-lived sessions
|
|
1221
|
+
|
|
1222
|
+
### 6. Rate Limiting
|
|
1223
|
+
|
|
1224
|
+
Implement rate limiting on authentication endpoints to prevent brute force attacks.
|
|
1225
|
+
|
|
1226
|
+
## TypeScript Types
|
|
1227
|
+
|
|
1228
|
+
```typescript
|
|
1229
|
+
import { JwtAuthPlugin, JwtAuthPluginOptions } from "@flink-app/jwt-auth-plugin";
|
|
1230
|
+
|
|
1231
|
+
// Token extractor callback type
|
|
1232
|
+
type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
|
|
1233
|
+
|
|
1234
|
+
// Permission checker callback type
|
|
1235
|
+
type PermissionChecker = (
|
|
1236
|
+
user: FlinkAuthUser,
|
|
1237
|
+
routePermissions: string[]
|
|
1238
|
+
) => Promise<boolean> | boolean;
|
|
1239
|
+
|
|
1240
|
+
// Plugin options
|
|
1241
|
+
interface JwtAuthPluginOptions {
|
|
1242
|
+
secret: string;
|
|
1243
|
+
algo?: jwtSimple.TAlgorithm;
|
|
1244
|
+
getUser: (tokenData: any) => Promise<FlinkAuthUser>;
|
|
1245
|
+
passwordPolicy?: RegExp;
|
|
1246
|
+
tokenTTL?: number;
|
|
1247
|
+
rolePermissions: {
|
|
1248
|
+
[role: string]: string[];
|
|
1249
|
+
};
|
|
1250
|
+
tokenExtractor?: TokenExtractor;
|
|
1251
|
+
checkPermissions?: PermissionChecker;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// Plugin interface
|
|
1255
|
+
interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
1256
|
+
createToken: (payload: any, roles: string[]) => Promise<string>;
|
|
1257
|
+
createPasswordHashAndSalt: (
|
|
1258
|
+
password: string
|
|
1259
|
+
) => Promise<{ hash: string; salt: string } | null>;
|
|
1260
|
+
validatePassword: (
|
|
1261
|
+
password: string,
|
|
1262
|
+
passwordHash: string,
|
|
1263
|
+
salt: string
|
|
1264
|
+
) => Promise<boolean>;
|
|
31
1265
|
}
|
|
32
1266
|
|
|
1267
|
+
// Authenticated user (from Flink framework)
|
|
1268
|
+
interface FlinkAuthUser {
|
|
1269
|
+
id: string;
|
|
1270
|
+
username?: string;
|
|
1271
|
+
roles?: string[];
|
|
1272
|
+
[key: string]: any;
|
|
1273
|
+
}
|
|
33
1274
|
```
|
|
1275
|
+
|
|
1276
|
+
## Troubleshooting
|
|
1277
|
+
|
|
1278
|
+
### Token Validation Fails
|
|
1279
|
+
|
|
1280
|
+
**Issue:** Requests return 401 Unauthorized
|
|
1281
|
+
|
|
1282
|
+
**Solutions:**
|
|
1283
|
+
- Verify the token is being sent in the `Authorization` header
|
|
1284
|
+
- Check the header format: `Authorization: Bearer <token>`
|
|
1285
|
+
- Ensure the secret used to sign matches the secret used to verify
|
|
1286
|
+
- Check if the token has expired (if TTL is configured)
|
|
1287
|
+
|
|
1288
|
+
### Password Creation Returns Null
|
|
1289
|
+
|
|
1290
|
+
**Issue:** `createPasswordHashAndSalt` returns `null`
|
|
1291
|
+
|
|
1292
|
+
**Solution:** Password doesn't meet the configured `passwordPolicy`. Update the password or adjust the policy.
|
|
1293
|
+
|
|
1294
|
+
### getUser Function Errors
|
|
1295
|
+
|
|
1296
|
+
**Issue:** Authentication fails with error from `getUser`
|
|
1297
|
+
|
|
1298
|
+
**Solution:** Ensure your `getUser` function properly handles missing users:
|
|
1299
|
+
|
|
1300
|
+
```typescript
|
|
1301
|
+
getUser: async (tokenData) => {
|
|
1302
|
+
const user = await ctx.repos.userRepo.findById(tokenData.userId);
|
|
1303
|
+
if (!user) {
|
|
1304
|
+
throw new Error("User not found");
|
|
1305
|
+
}
|
|
1306
|
+
return {
|
|
1307
|
+
id: user._id,
|
|
1308
|
+
username: user.username,
|
|
1309
|
+
roles: user.roles,
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
## License
|
|
1315
|
+
|
|
1316
|
+
MIT
|