@flink-app/jwt-auth-plugin 1.0.0 → 2.0.0-alpha.48
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 +6 -0
- package/dist/FlinkJwtAuthPlugin.js +43 -0
- package/package.json +2 -2
- package/readme.md +247 -0
- package/src/FlinkJwtAuthPlugin.ts +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -149,6 +149,12 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
|
|
|
149
149
|
_c.label = 3;
|
|
150
150
|
case 3:
|
|
151
151
|
req.user = user;
|
|
152
|
+
// Populate userPermissions from resolved permissions
|
|
153
|
+
req.userPermissions = computeUserPermissions(user, decodedToken, {
|
|
154
|
+
useDynamicRoles: useDynamicRoles,
|
|
155
|
+
rolePermissions: rolePermissions,
|
|
156
|
+
checkPermissions: checkPermissions,
|
|
157
|
+
});
|
|
152
158
|
return [2 /*return*/, true];
|
|
153
159
|
case 4: return [2 /*return*/, false];
|
|
154
160
|
}
|
|
@@ -211,3 +217,40 @@ function validatePassword(password, passwordHash, salt) {
|
|
|
211
217
|
});
|
|
212
218
|
});
|
|
213
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Compute user permissions based on auth plugin configuration
|
|
222
|
+
*
|
|
223
|
+
* Strategies (in order):
|
|
224
|
+
* 1. User object has permissions directly (from getUser callback)
|
|
225
|
+
* 2. Dynamic roles - map user.roles to permissions
|
|
226
|
+
* 3. Static roles from token - map token.roles to permissions
|
|
227
|
+
* 4. No permissions available - return empty array
|
|
228
|
+
*/
|
|
229
|
+
function computeUserPermissions(user, decodedToken, config) {
|
|
230
|
+
// Strategy 1: User object has permissions directly (from getUser callback)
|
|
231
|
+
if (user.permissions && Array.isArray(user.permissions)) {
|
|
232
|
+
return user.permissions;
|
|
233
|
+
}
|
|
234
|
+
// Strategy 2: Dynamic roles - map user.roles to permissions
|
|
235
|
+
if (config.useDynamicRoles && user.roles && Array.isArray(user.roles)) {
|
|
236
|
+
return flattenRolesToPermissions(user.roles, config.rolePermissions);
|
|
237
|
+
}
|
|
238
|
+
// Strategy 3: Static roles from token - map token.roles to permissions
|
|
239
|
+
if (decodedToken.roles && Array.isArray(decodedToken.roles)) {
|
|
240
|
+
return flattenRolesToPermissions(decodedToken.roles, config.rolePermissions);
|
|
241
|
+
}
|
|
242
|
+
// Strategy 4: No permissions available
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Convert roles to permissions using rolePermissions mapping
|
|
247
|
+
*/
|
|
248
|
+
function flattenRolesToPermissions(roles, roleMap) {
|
|
249
|
+
var perms = new Set();
|
|
250
|
+
for (var _i = 0, roles_1 = roles; _i < roles_1.length; _i++) {
|
|
251
|
+
var role = roles_1[_i];
|
|
252
|
+
var rolePerms = roleMap[role] || [];
|
|
253
|
+
rolePerms.forEach(function (p) { return perms.add(p); });
|
|
254
|
+
}
|
|
255
|
+
return Array.from(perms);
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flink-app/jwt-auth-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-alpha.48",
|
|
4
4
|
"description": "Flink plugin for JWT auth",
|
|
5
5
|
"author": "joel@frost.se",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"@types/node": "22.13.10",
|
|
20
20
|
"ts-node": "^10.9.2",
|
|
21
21
|
"tsc-watch": "^4.2.9",
|
|
22
|
-
"@flink-app/flink": "
|
|
22
|
+
"@flink-app/flink": "2.0.0-alpha.48"
|
|
23
23
|
},
|
|
24
24
|
"gitHead": "4243e3b3cd6d4e1ca001a61baa8436bf2bbe4113",
|
|
25
25
|
"scripts": {
|
package/readme.md
CHANGED
|
@@ -443,6 +443,253 @@ jwtAuthPlugin({
|
|
|
443
443
|
|
|
444
444
|
**Solution**: Clear permission cache or reduce cache TTL
|
|
445
445
|
|
|
446
|
+
## `req.userPermissions` - AI Permissions Integration
|
|
447
|
+
|
|
448
|
+
Starting in **v1.1.0**, the JWT auth plugin automatically populates `req.userPermissions` with the resolved permissions array. This field is specifically designed for use with Flink's AI agents and tools system, providing a standardized way to handle permissions across handlers, agents, and tools.
|
|
449
|
+
|
|
450
|
+
### Why `req.userPermissions`?
|
|
451
|
+
|
|
452
|
+
**Before v1.1.0**: Tools and agents had to manually convert roles to permissions or query the database in permission functions, leading to:
|
|
453
|
+
- Duplicated role-to-permission mapping logic
|
|
454
|
+
- Performance issues (N tools × M agent steps = many DB queries)
|
|
455
|
+
- No support for multi-tenant permission contexts
|
|
456
|
+
- Inconsistent permission resolution across the app
|
|
457
|
+
|
|
458
|
+
**After v1.1.0**: The auth plugin populates `req.userPermissions` once during authentication, and this resolved array is passed through to agents and tools:
|
|
459
|
+
- ✅ Single source of truth for permissions
|
|
460
|
+
- ✅ Performance optimization (computed once per request)
|
|
461
|
+
- ✅ Reuses all auth plugin features (roles, dynamic roles, custom checkers)
|
|
462
|
+
- ✅ Consistent permission checking across handlers, agents, and tools
|
|
463
|
+
|
|
464
|
+
### How It Works
|
|
465
|
+
|
|
466
|
+
The JWT auth plugin automatically populates `req.userPermissions` based on the configuration strategy:
|
|
467
|
+
|
|
468
|
+
**Strategy 1: Direct permissions from user object**
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
jwtAuthPlugin({
|
|
472
|
+
secret: process.env.JWT_SECRET,
|
|
473
|
+
getUser: async (tokenData) => {
|
|
474
|
+
const user = await userRepo.getById(tokenData.userId);
|
|
475
|
+
return {
|
|
476
|
+
id: user.id,
|
|
477
|
+
permissions: user.permissions, // Direct permissions array
|
|
478
|
+
};
|
|
479
|
+
},
|
|
480
|
+
rolePermissions: {},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Result: req.userPermissions = user.permissions
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
**Strategy 2: Static role mapping**
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
jwtAuthPlugin({
|
|
490
|
+
secret: process.env.JWT_SECRET,
|
|
491
|
+
getUser: async (tokenData) => {
|
|
492
|
+
return { id: tokenData.userId };
|
|
493
|
+
},
|
|
494
|
+
rolePermissions: {
|
|
495
|
+
admin: ["car:read", "car:create", "car:delete"],
|
|
496
|
+
user: ["car:read"],
|
|
497
|
+
premium: ["car:read", "car:create", "premium-features"],
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// JWT token has roles: ["user", "premium"]
|
|
502
|
+
// Result: req.userPermissions = ["car:read", "car:create", "premium-features"]
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
**Strategy 3: Dynamic roles (multi-tenant)**
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
jwtAuthPlugin({
|
|
509
|
+
secret: process.env.JWT_SECRET,
|
|
510
|
+
useDynamicRoles: true,
|
|
511
|
+
getUser: async (tokenData, req) => {
|
|
512
|
+
const orgId = req.headers['x-organization-id'];
|
|
513
|
+
const membership = await orgMembershipRepo.getByUserAndOrg(
|
|
514
|
+
tokenData.userId,
|
|
515
|
+
orgId
|
|
516
|
+
);
|
|
517
|
+
return {
|
|
518
|
+
id: tokenData.userId,
|
|
519
|
+
roles: [membership.role], // Org-specific role
|
|
520
|
+
};
|
|
521
|
+
},
|
|
522
|
+
rolePermissions: {
|
|
523
|
+
admin: ["car:read", "car:create", "car:delete"],
|
|
524
|
+
user: ["car:read"],
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Same user, different orgs → different permissions
|
|
529
|
+
// Org A: admin role → req.userPermissions = ["car:read", "car:create", "car:delete"]
|
|
530
|
+
// Org B: user role → req.userPermissions = ["car:read"]
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Using with AI Agents and Tools
|
|
534
|
+
|
|
535
|
+
Pass `req.userPermissions` to agents in your handlers:
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
// Handler
|
|
539
|
+
const handler: Handler<AppCtx, RequestBody, ResponseBody> = async ({ req, ctx }) => {
|
|
540
|
+
const agent = ctx.agents.carAgent
|
|
541
|
+
.withUser(req.user)
|
|
542
|
+
.withPermissions(req.userPermissions); // Pass resolved permissions
|
|
543
|
+
|
|
544
|
+
return await agent.execute({ message: req.body.message });
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Agent
|
|
548
|
+
export default class CarAgent extends FlinkAgent<AppCtx> {
|
|
549
|
+
description = "Expert in car models";
|
|
550
|
+
instructions = "You are a car expert...";
|
|
551
|
+
tools = ["get-cars-tool", "create-car-tool"];
|
|
552
|
+
permissions = "car:access"; // Agent-level permission
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Tool
|
|
556
|
+
export const Tool: FlinkToolProps = {
|
|
557
|
+
id: "create-car",
|
|
558
|
+
description: "Create a new car",
|
|
559
|
+
permissions: "car:create", // Tool-level permission
|
|
560
|
+
inputSchema: z.object({
|
|
561
|
+
brand: z.string(),
|
|
562
|
+
model: z.string(),
|
|
563
|
+
}),
|
|
564
|
+
};
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
**What happens:**
|
|
568
|
+
1. Auth plugin resolves permissions → `req.userPermissions = ["car:access", "car:read", "car:create"]`
|
|
569
|
+
2. Agent checks if user has `"car:access"` → ✅ allowed
|
|
570
|
+
3. LLM only sees tools user has permission for (`"create-car"` is shown, others hidden)
|
|
571
|
+
4. Tool execution checks `"car:create"` permission → ✅ allowed
|
|
572
|
+
|
|
573
|
+
### Permission Resolution Priority
|
|
574
|
+
|
|
575
|
+
The plugin computes `req.userPermissions` in this order:
|
|
576
|
+
|
|
577
|
+
1. **Direct permissions**: If `user.permissions` exists (from `getUser`), use it directly
|
|
578
|
+
2. **Dynamic roles**: If `useDynamicRoles: true`, map `user.roles` to permissions via `rolePermissions`
|
|
579
|
+
3. **Static roles**: Map token roles to permissions via `rolePermissions`
|
|
580
|
+
4. **Empty array**: If no permissions found, return `[]`
|
|
581
|
+
|
|
582
|
+
### Backward Compatibility
|
|
583
|
+
|
|
584
|
+
`req.userPermissions` is **optional** - existing apps continue to work:
|
|
585
|
+
|
|
586
|
+
- If not using AI agents/tools → field is populated but not used
|
|
587
|
+
- Tools/agents fall back to `user.permissions` if `req.userPermissions` is not passed
|
|
588
|
+
- No breaking changes to existing code
|
|
589
|
+
|
|
590
|
+
### Performance Benefits
|
|
591
|
+
|
|
592
|
+
**Before** (function-based permissions without `userPermissions`):
|
|
593
|
+
```typescript
|
|
594
|
+
// Permission checked N times (once per tool use)
|
|
595
|
+
permissions: async (input, user) => {
|
|
596
|
+
const perms = await fetchUserPermissions(user.id); // DB query per tool!
|
|
597
|
+
return perms.includes("car:create");
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
**After** (using `userPermissions`):
|
|
602
|
+
```typescript
|
|
603
|
+
// Permissions computed ONCE during authentication
|
|
604
|
+
req.userPermissions = ["car:read", "car:create"]; // Happens once
|
|
605
|
+
|
|
606
|
+
// Tools check the array (fast O(1) lookup)
|
|
607
|
+
permissions: "car:create"
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
**Result**: Single DB query/computation per request, regardless of how many tools or agent steps are used.
|
|
611
|
+
|
|
612
|
+
### Example: Complete Integration
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
// 1. Configure JWT auth plugin
|
|
616
|
+
jwtAuthPlugin({
|
|
617
|
+
secret: process.env.JWT_SECRET,
|
|
618
|
+
rolePermissions: {
|
|
619
|
+
admin: ["car:read", "car:create", "car:delete", "premium-features"],
|
|
620
|
+
user: ["car:read"],
|
|
621
|
+
premium: ["car:read", "car:create", "premium-features"],
|
|
622
|
+
},
|
|
623
|
+
getUser: async (tokenData) => {
|
|
624
|
+
const user = await userRepo.getById(tokenData.userId);
|
|
625
|
+
return {
|
|
626
|
+
id: user.id,
|
|
627
|
+
email: user.email,
|
|
628
|
+
// Roles from DB or token will be mapped to permissions automatically
|
|
629
|
+
};
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// 2. Handler passes userPermissions to agent
|
|
634
|
+
const handler: Handler<AppCtx, { question: string }, { answer: string }> = async ({
|
|
635
|
+
req,
|
|
636
|
+
ctx,
|
|
637
|
+
}) => {
|
|
638
|
+
const agent = ctx.agents.carAgent
|
|
639
|
+
.withUser(req.user)
|
|
640
|
+
.withPermissions(req.userPermissions); // Resolved permissions passed here
|
|
641
|
+
|
|
642
|
+
const response = await agent.execute({ message: req.body.question });
|
|
643
|
+
return { data: { answer: response.message } };
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// 3. Agent defines permissions
|
|
647
|
+
export default class CarAgent extends FlinkAgent<AppCtx> {
|
|
648
|
+
description = "Car inventory expert";
|
|
649
|
+
instructions = "Help users with car-related queries";
|
|
650
|
+
tools = ["get-cars", "create-car", "premium-report"];
|
|
651
|
+
permissions = "car:read"; // User must have car:read
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 4. Tools define individual permissions
|
|
655
|
+
export const GetCarsTool: FlinkToolProps = {
|
|
656
|
+
id: "get-cars",
|
|
657
|
+
description: "Search car inventory",
|
|
658
|
+
permissions: "car:read", // Basic read access
|
|
659
|
+
inputSchema: z.object({ brand: z.string().optional() }),
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
export const CreateCarTool: FlinkToolProps = {
|
|
663
|
+
id: "create-car",
|
|
664
|
+
description: "Add new car to inventory",
|
|
665
|
+
permissions: "car:create", // Requires create permission
|
|
666
|
+
inputSchema: z.object({ brand: z.string(), model: z.string() }),
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
export const PremiumReportTool: FlinkToolProps = {
|
|
670
|
+
id: "premium-report",
|
|
671
|
+
description: "Generate advanced analytics",
|
|
672
|
+
permissions: ["car:read", "premium-features"], // Requires BOTH
|
|
673
|
+
inputSchema: z.object({ carId: z.string() }),
|
|
674
|
+
};
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**Flow**:
|
|
678
|
+
1. User with JWT token containing `roles: ["user", "premium"]` makes a request
|
|
679
|
+
2. Auth plugin resolves: `req.userPermissions = ["car:read", "car:create", "premium-features"]`
|
|
680
|
+
3. Handler passes `req.userPermissions` to agent
|
|
681
|
+
4. Agent checks `"car:read"` → ✅ user has it
|
|
682
|
+
5. Agent filters tools:
|
|
683
|
+
- `get-cars` (requires `"car:read"`) → ✅ shown to LLM
|
|
684
|
+
- `create-car` (requires `"car:create"`) → ✅ shown to LLM
|
|
685
|
+
- `premium-report` (requires `["car:read", "premium-features"]`) → ✅ shown to LLM (has both)
|
|
686
|
+
6. LLM can use all three tools
|
|
687
|
+
|
|
688
|
+
If user only had `roles: ["user"]`:
|
|
689
|
+
- `req.userPermissions = ["car:read"]`
|
|
690
|
+
- Only `get-cars` tool would be shown to LLM
|
|
691
|
+
- LLM cannot hallucinate using `create-car` or `premium-report` (they're hidden)
|
|
692
|
+
|
|
446
693
|
## Multi-Tenant & Dynamic Roles
|
|
447
694
|
|
|
448
695
|
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`.
|
|
@@ -219,6 +219,14 @@ async function authenticateRequest(
|
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
req.user = user;
|
|
222
|
+
|
|
223
|
+
// Populate userPermissions from resolved permissions
|
|
224
|
+
req.userPermissions = computeUserPermissions(user, decodedToken, {
|
|
225
|
+
useDynamicRoles,
|
|
226
|
+
rolePermissions,
|
|
227
|
+
checkPermissions,
|
|
228
|
+
});
|
|
229
|
+
|
|
222
230
|
return true;
|
|
223
231
|
}
|
|
224
232
|
}
|
|
@@ -261,3 +269,52 @@ async function validatePassword(password: string, passwordHash: string, salt: st
|
|
|
261
269
|
const hashCandidate = await encrypt(password, salt);
|
|
262
270
|
return hashCandidate === passwordHash;
|
|
263
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Compute user permissions based on auth plugin configuration
|
|
275
|
+
*
|
|
276
|
+
* Strategies (in order):
|
|
277
|
+
* 1. User object has permissions directly (from getUser callback)
|
|
278
|
+
* 2. Dynamic roles - map user.roles to permissions
|
|
279
|
+
* 3. Static roles from token - map token.roles to permissions
|
|
280
|
+
* 4. No permissions available - return empty array
|
|
281
|
+
*/
|
|
282
|
+
function computeUserPermissions(
|
|
283
|
+
user: any,
|
|
284
|
+
decodedToken: any,
|
|
285
|
+
config: {
|
|
286
|
+
useDynamicRoles?: boolean;
|
|
287
|
+
rolePermissions: { [x: string]: string[] };
|
|
288
|
+
checkPermissions?: PermissionChecker;
|
|
289
|
+
}
|
|
290
|
+
): string[] {
|
|
291
|
+
// Strategy 1: User object has permissions directly (from getUser callback)
|
|
292
|
+
if (user.permissions && Array.isArray(user.permissions)) {
|
|
293
|
+
return user.permissions;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Strategy 2: Dynamic roles - map user.roles to permissions
|
|
297
|
+
if (config.useDynamicRoles && user.roles && Array.isArray(user.roles)) {
|
|
298
|
+
return flattenRolesToPermissions(user.roles, config.rolePermissions);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Strategy 3: Static roles from token - map token.roles to permissions
|
|
302
|
+
if (decodedToken.roles && Array.isArray(decodedToken.roles)) {
|
|
303
|
+
return flattenRolesToPermissions(decodedToken.roles, config.rolePermissions);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Strategy 4: No permissions available
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Convert roles to permissions using rolePermissions mapping
|
|
312
|
+
*/
|
|
313
|
+
function flattenRolesToPermissions(roles: string[], roleMap: Record<string, string[]>): string[] {
|
|
314
|
+
const perms = new Set<string>();
|
|
315
|
+
for (const role of roles) {
|
|
316
|
+
const rolePerms = roleMap[role] || [];
|
|
317
|
+
rolePerms.forEach((p) => perms.add(p));
|
|
318
|
+
}
|
|
319
|
+
return Array.from(perms);
|
|
320
|
+
}
|