@flink-app/jwt-auth-plugin 0.12.1-alpha.41 → 0.12.1-alpha.44
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/dist/FlinkJwtAuthPlugin.d.ts +23 -2
- package/dist/FlinkJwtAuthPlugin.js +15 -6
- package/package.json +3 -3
- package/readme.md +334 -1
- package/spec/FlinkJwtAuthPlugin.spec.ts +260 -16
- package/src/FlinkJwtAuthPlugin.ts +39 -6
|
@@ -23,7 +23,7 @@ export type PermissionChecker = (user: FlinkAuthUser, routePermissions: string[]
|
|
|
23
23
|
export interface JwtAuthPluginOptions {
|
|
24
24
|
secret: string;
|
|
25
25
|
algo?: jwtSimple.TAlgorithm;
|
|
26
|
-
getUser: (tokenData: any) => Promise<FlinkAuthUser | null | undefined>;
|
|
26
|
+
getUser: (tokenData: any, req: FlinkRequest) => Promise<FlinkAuthUser | null | undefined>;
|
|
27
27
|
passwordPolicy?: RegExp;
|
|
28
28
|
tokenTTL?: number;
|
|
29
29
|
rolePermissions: {
|
|
@@ -52,6 +52,27 @@ export interface JwtAuthPluginOptions {
|
|
|
52
52
|
* ```
|
|
53
53
|
*/
|
|
54
54
|
checkPermissions?: PermissionChecker;
|
|
55
|
+
/**
|
|
56
|
+
* When true, uses roles from the user object returned by getUser
|
|
57
|
+
* instead of roles from the decoded token for static permission checking.
|
|
58
|
+
*
|
|
59
|
+
* Useful for multi-tenant scenarios where user roles vary by organization context.
|
|
60
|
+
* The organization context can be determined from request headers, subdomain, path, etc.
|
|
61
|
+
*
|
|
62
|
+
* Example:
|
|
63
|
+
* ```typescript
|
|
64
|
+
* useDynamicRoles: true,
|
|
65
|
+
* getUser: async (tokenData, req) => {
|
|
66
|
+
* const orgId = req.headers['x-organization-id'];
|
|
67
|
+
* const membership = await getOrgMembership(tokenData.userId, orgId);
|
|
68
|
+
* return {
|
|
69
|
+
* id: tokenData.userId,
|
|
70
|
+
* roles: [membership.role], // Org-specific role
|
|
71
|
+
* };
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
useDynamicRoles?: boolean;
|
|
55
76
|
}
|
|
56
77
|
export interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
57
78
|
/**
|
|
@@ -82,4 +103,4 @@ export interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
|
82
103
|
* Configures and creates authentication plugin.
|
|
83
104
|
*/
|
|
84
105
|
export declare function jwtAuthPlugin({ secret, getUser, rolePermissions, algo, passwordPolicy, tokenTTL, //Defaults to hundred year
|
|
85
|
-
tokenExtractor, checkPermissions, }: JwtAuthPluginOptions): JwtAuthPlugin;
|
|
106
|
+
tokenExtractor, checkPermissions, useDynamicRoles, }: JwtAuthPluginOptions): JwtAuthPlugin;
|
|
@@ -66,7 +66,7 @@ var defaultPasswordPolicy = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
|
|
|
66
66
|
function jwtAuthPlugin(_a) {
|
|
67
67
|
var _this = this;
|
|
68
68
|
var secret = _a.secret, getUser = _a.getUser, rolePermissions = _a.rolePermissions, _b = _a.algo, algo = _b === void 0 ? "HS256" : _b, _c = _a.passwordPolicy, passwordPolicy = _c === void 0 ? defaultPasswordPolicy : _c, _d = _a.tokenTTL, tokenTTL = _d === void 0 ? 1000 * 60 * 60 * 24 * 365 * 100 : _d, //Defaults to hundred year
|
|
69
|
-
tokenExtractor = _a.tokenExtractor, checkPermissions = _a.checkPermissions;
|
|
69
|
+
tokenExtractor = _a.tokenExtractor, checkPermissions = _a.checkPermissions, _e = _a.useDynamicRoles, useDynamicRoles = _e === void 0 ? false : _e;
|
|
70
70
|
return {
|
|
71
71
|
authenticateRequest: function (req, permissions) { return __awaiter(_this, void 0, void 0, function () {
|
|
72
72
|
return __generator(this, function (_a) {
|
|
@@ -76,6 +76,7 @@ function jwtAuthPlugin(_a) {
|
|
|
76
76
|
getUser: getUser,
|
|
77
77
|
tokenExtractor: tokenExtractor,
|
|
78
78
|
checkPermissions: checkPermissions,
|
|
79
|
+
useDynamicRoles: useDynamicRoles,
|
|
79
80
|
})];
|
|
80
81
|
});
|
|
81
82
|
}); },
|
|
@@ -87,8 +88,8 @@ function jwtAuthPlugin(_a) {
|
|
|
87
88
|
exports.jwtAuthPlugin = jwtAuthPlugin;
|
|
88
89
|
function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
|
|
89
90
|
return __awaiter(this, arguments, void 0, function (req, routePermissions, rolePermissions, _b) {
|
|
90
|
-
var token, decodedToken, permissionsArr, validPerms, user, hasPermission;
|
|
91
|
-
var secret = _b.secret, algo = _b.algo, getUser = _b.getUser, tokenExtractor = _b.tokenExtractor, checkPermissions = _b.checkPermissions;
|
|
91
|
+
var token, decodedToken, permissionsArr, validPerms, user, validPerms, hasPermission;
|
|
92
|
+
var secret = _b.secret, algo = _b.algo, getUser = _b.getUser, tokenExtractor = _b.tokenExtractor, checkPermissions = _b.checkPermissions, useDynamicRoles = _b.useDynamicRoles;
|
|
92
93
|
return __generator(this, function (_c) {
|
|
93
94
|
switch (_c.label) {
|
|
94
95
|
case 0:
|
|
@@ -116,20 +117,28 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
|
|
|
116
117
|
}
|
|
117
118
|
if (!decodedToken) return [3 /*break*/, 4];
|
|
118
119
|
permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
|
|
119
|
-
// Static permission check - only if custom checker NOT provided
|
|
120
|
-
if (!checkPermissions && permissionsArr && permissionsArr.length > 0) {
|
|
120
|
+
// Static permission check - only if custom checker NOT provided AND not using dynamic roles
|
|
121
|
+
if (!checkPermissions && !useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
|
|
121
122
|
validPerms = (0, PermissionValidator_1.hasValidPermissions)(decodedToken.roles || [], rolePermissions, permissionsArr);
|
|
122
123
|
if (!validPerms) {
|
|
123
124
|
return [2 /*return*/, false];
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
|
-
return [4 /*yield*/, getUser(decodedToken)];
|
|
127
|
+
return [4 /*yield*/, getUser(decodedToken, req)];
|
|
127
128
|
case 1:
|
|
128
129
|
user = _c.sent();
|
|
129
130
|
if (!user) {
|
|
130
131
|
flink_1.log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
|
|
131
132
|
return [2 /*return*/, false];
|
|
132
133
|
}
|
|
134
|
+
// Dynamic roles: check permissions using roles from user object
|
|
135
|
+
if (!checkPermissions && useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
|
|
136
|
+
validPerms = (0, PermissionValidator_1.hasValidPermissions)(user.roles || [], rolePermissions, permissionsArr);
|
|
137
|
+
if (!validPerms) {
|
|
138
|
+
flink_1.log.debug("[JWT AUTH PLUGIN] Dynamic role permission check failed");
|
|
139
|
+
return [2 /*return*/, false];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
133
142
|
if (!(checkPermissions && permissionsArr && permissionsArr.length > 0)) return [3 /*break*/, 3];
|
|
134
143
|
return [4 /*yield*/, checkPermissions(user, permissionsArr)];
|
|
135
144
|
case 2:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flink-app/jwt-auth-plugin",
|
|
3
|
-
"version": "0.12.1-alpha.
|
|
3
|
+
"version": "0.12.1-alpha.44",
|
|
4
4
|
"description": "Flink plugin for JWT auth",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --preserve-symlinks -r ts-node/register -- node_modules/jasmine/bin/jasmine --config=./spec/support/jasmine.json",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"jwt-simple": "^0.5.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@flink-app/flink": "^0.12.1-alpha.
|
|
23
|
+
"@flink-app/flink": "^0.12.1-alpha.44",
|
|
24
24
|
"@types/bcrypt": "^5.0.0",
|
|
25
25
|
"@types/jasmine": "^3.7.1",
|
|
26
26
|
"@types/node": "22.13.10",
|
|
@@ -31,5 +31,5 @@
|
|
|
31
31
|
"tsc-watch": "^4.2.9",
|
|
32
32
|
"typescript": "5.4.5"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "4243e3b3cd6d4e1ca001a61baa8436bf2bbe4113"
|
|
35
35
|
}
|
package/readme.md
CHANGED
|
@@ -66,13 +66,14 @@ start();
|
|
|
66
66
|
| Option | Type | Required | Default | Description |
|
|
67
67
|
|--------|------|----------|---------|-------------|
|
|
68
68
|
| `secret` | `string` | Yes | - | Secret key used to sign and verify JWT tokens. Keep this secure! |
|
|
69
|
-
| `getUser` | `(tokenData: any) => Promise<FlinkAuthUser>` | Yes | - | Async function that retrieves user data from token payload |
|
|
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
70
|
| `rolePermissions` | `{ [role: string]: string[] }` | Yes | - | Maps roles to their allowed permissions |
|
|
71
71
|
| `algo` | `jwtSimple.TAlgorithm` | No | `"HS256"` | JWT signing algorithm |
|
|
72
72
|
| `passwordPolicy` | `RegExp` | No | `/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/` | Regex to validate password strength |
|
|
73
73
|
| `tokenTTL` | `number` | No | `1000 * 60 * 60 * 24 * 365 * 100` (100 years) | Token time-to-live in milliseconds |
|
|
74
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
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 |
|
|
76
77
|
|
|
77
78
|
### Default Password Policy
|
|
78
79
|
|
|
@@ -442,6 +443,338 @@ jwtAuthPlugin({
|
|
|
442
443
|
|
|
443
444
|
**Solution**: Clear permission cache or reduce cache TTL
|
|
444
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
|
+
|
|
445
778
|
## Context API
|
|
446
779
|
|
|
447
780
|
Once configured, the plugin provides the following methods via the `auth` context:
|
|
@@ -6,7 +6,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
6
6
|
it("should create and configure plugin", () => {
|
|
7
7
|
const plugin = jwtAuthPlugin({
|
|
8
8
|
secret: "secret",
|
|
9
|
-
getUser: async (id: string) => {
|
|
9
|
+
getUser: async (id: string, req) => {
|
|
10
10
|
return {
|
|
11
11
|
id,
|
|
12
12
|
username: "username",
|
|
@@ -21,7 +21,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
21
21
|
it("should fail auth if no token was provided", async () => {
|
|
22
22
|
const plugin = jwtAuthPlugin({
|
|
23
23
|
secret: "secret",
|
|
24
|
-
getUser: async (id: string) => {
|
|
24
|
+
getUser: async (id: string, req) => {
|
|
25
25
|
return {
|
|
26
26
|
id,
|
|
27
27
|
username: "username",
|
|
@@ -46,7 +46,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
46
46
|
it("should fail auth if token is invalid provided", async () => {
|
|
47
47
|
const plugin = jwtAuthPlugin({
|
|
48
48
|
secret: "secret",
|
|
49
|
-
getUser: async (id: string) => {
|
|
49
|
+
getUser: async (id: string, req) => {
|
|
50
50
|
fail(); // Should not invoke this
|
|
51
51
|
return {
|
|
52
52
|
id,
|
|
@@ -79,7 +79,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
79
79
|
|
|
80
80
|
const plugin = jwtAuthPlugin({
|
|
81
81
|
secret,
|
|
82
|
-
getUser: async ({ id }: { id: string }) => {
|
|
82
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
83
83
|
expect(id).toBe(userId);
|
|
84
84
|
return {
|
|
85
85
|
id,
|
|
@@ -106,7 +106,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
106
106
|
const secret = "secret";
|
|
107
107
|
const plugin = jwtAuthPlugin({
|
|
108
108
|
secret,
|
|
109
|
-
getUser: async (id: string) => {
|
|
109
|
+
getUser: async (id: string, req) => {
|
|
110
110
|
fail(); // Should not invoke this
|
|
111
111
|
return {
|
|
112
112
|
id,
|
|
@@ -138,7 +138,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
138
138
|
|
|
139
139
|
const plugin = jwtAuthPlugin({
|
|
140
140
|
secret,
|
|
141
|
-
getUser: async ({ id }: { id: string }) => {
|
|
141
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
142
142
|
expect(id).toBe(userId);
|
|
143
143
|
return {
|
|
144
144
|
id,
|
|
@@ -169,7 +169,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
169
169
|
it("should fail auth when tokenExtractor returns null", async () => {
|
|
170
170
|
const plugin = jwtAuthPlugin({
|
|
171
171
|
secret: "secret",
|
|
172
|
-
getUser: async (id: string) => {
|
|
172
|
+
getUser: async (id: string, req) => {
|
|
173
173
|
fail(); // Should not be called
|
|
174
174
|
return {
|
|
175
175
|
id,
|
|
@@ -206,7 +206,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
206
206
|
|
|
207
207
|
const plugin = jwtAuthPlugin({
|
|
208
208
|
secret,
|
|
209
|
-
getUser: async ({ id }: { id: string }) => {
|
|
209
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
210
210
|
expect(id).toBe(userId);
|
|
211
211
|
return {
|
|
212
212
|
id,
|
|
@@ -247,7 +247,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
247
247
|
|
|
248
248
|
const plugin = jwtAuthPlugin({
|
|
249
249
|
secret,
|
|
250
|
-
getUser: async ({ id }: { id: string }) => {
|
|
250
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
251
251
|
return {
|
|
252
252
|
id,
|
|
253
253
|
username: "username",
|
|
@@ -300,7 +300,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
300
300
|
|
|
301
301
|
const plugin = jwtAuthPlugin({
|
|
302
302
|
secret,
|
|
303
|
-
getUser: async ({ id }: { id: string }) => {
|
|
303
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
304
304
|
return {
|
|
305
305
|
id,
|
|
306
306
|
username: "username",
|
|
@@ -335,7 +335,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
335
335
|
|
|
336
336
|
const plugin = jwtAuthPlugin({
|
|
337
337
|
secret,
|
|
338
|
-
getUser: async ({ id }: { id: string }) => {
|
|
338
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
339
339
|
return {
|
|
340
340
|
id,
|
|
341
341
|
username: "testuser",
|
|
@@ -382,7 +382,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
382
382
|
|
|
383
383
|
const plugin = jwtAuthPlugin({
|
|
384
384
|
secret,
|
|
385
|
-
getUser: async ({ id }: { id: string }) => {
|
|
385
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
386
386
|
return {
|
|
387
387
|
id,
|
|
388
388
|
username: "testuser",
|
|
@@ -422,7 +422,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
422
422
|
|
|
423
423
|
const plugin = jwtAuthPlugin({
|
|
424
424
|
secret,
|
|
425
|
-
getUser: async ({ id }: { id: string }) => {
|
|
425
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
426
426
|
return {
|
|
427
427
|
id,
|
|
428
428
|
username: "admin",
|
|
@@ -458,7 +458,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
458
458
|
|
|
459
459
|
const plugin = jwtAuthPlugin({
|
|
460
460
|
secret,
|
|
461
|
-
getUser: async ({ id }: { id: string }) => {
|
|
461
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
462
462
|
return {
|
|
463
463
|
id,
|
|
464
464
|
username: "testuser",
|
|
@@ -500,7 +500,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
500
500
|
|
|
501
501
|
const plugin = jwtAuthPlugin({
|
|
502
502
|
secret,
|
|
503
|
-
getUser: async ({ id }: { id: string }) => {
|
|
503
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
504
504
|
return { id, username: "testuser" };
|
|
505
505
|
},
|
|
506
506
|
rolePermissions: {},
|
|
@@ -538,7 +538,7 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
538
538
|
|
|
539
539
|
const plugin = jwtAuthPlugin({
|
|
540
540
|
secret,
|
|
541
|
-
getUser: async ({ id }: { id: string }) => {
|
|
541
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
542
542
|
// Simulate fetching permissions from DB
|
|
543
543
|
const permissions = dbPermissions[id] || [];
|
|
544
544
|
return {
|
|
@@ -569,4 +569,248 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
569
569
|
expect(mockRequest.user?.permissions).toContain("custom_permission");
|
|
570
570
|
});
|
|
571
571
|
});
|
|
572
|
+
|
|
573
|
+
describe("useDynamicRoles", () => {
|
|
574
|
+
it("should use roles from user object when useDynamicRoles is true", async () => {
|
|
575
|
+
const secret = "secret";
|
|
576
|
+
const userId = "123";
|
|
577
|
+
// Token has one set of roles
|
|
578
|
+
const encodedToken = jwtSimple.encode(
|
|
579
|
+
{ id: userId, roles: ["guest"] }, // Token says guest
|
|
580
|
+
secret
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const plugin = jwtAuthPlugin({
|
|
584
|
+
secret,
|
|
585
|
+
useDynamicRoles: true,
|
|
586
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
587
|
+
// Simulate fetching org-specific role based on header
|
|
588
|
+
const orgId = req.headers["x-organization-id"];
|
|
589
|
+
const role = orgId === "org1" ? "admin" : "user";
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
id,
|
|
593
|
+
username: "testuser",
|
|
594
|
+
roles: [role], // Dynamic role from database/context
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
rolePermissions: {
|
|
598
|
+
admin: ["read", "write", "delete"],
|
|
599
|
+
user: ["read", "write"],
|
|
600
|
+
guest: ["read"],
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const mockRequest = {
|
|
605
|
+
headers: {
|
|
606
|
+
authorization: "Bearer " + encodedToken,
|
|
607
|
+
"x-organization-id": "org1",
|
|
608
|
+
},
|
|
609
|
+
} as unknown as FlinkRequest;
|
|
610
|
+
|
|
611
|
+
// Should pass because user is admin in org1 (despite token saying guest)
|
|
612
|
+
const authenticated = await plugin.authenticateRequest(mockRequest, ["delete"]);
|
|
613
|
+
|
|
614
|
+
expect(authenticated).toBeTruthy();
|
|
615
|
+
expect(mockRequest.user?.roles).toEqual(["admin"]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("should fail when dynamic role doesn't have required permission", async () => {
|
|
619
|
+
const secret = "secret";
|
|
620
|
+
const userId = "123";
|
|
621
|
+
const encodedToken = jwtSimple.encode(
|
|
622
|
+
{ id: userId, roles: ["admin"] }, // Token says admin
|
|
623
|
+
secret
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const plugin = jwtAuthPlugin({
|
|
627
|
+
secret,
|
|
628
|
+
useDynamicRoles: true,
|
|
629
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
630
|
+
const orgId = req.headers["x-organization-id"];
|
|
631
|
+
const role = orgId === "org2" ? "guest" : "admin";
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
id,
|
|
635
|
+
username: "testuser",
|
|
636
|
+
roles: [role],
|
|
637
|
+
};
|
|
638
|
+
},
|
|
639
|
+
rolePermissions: {
|
|
640
|
+
admin: ["read", "write", "delete"],
|
|
641
|
+
guest: ["read"],
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const mockRequest = {
|
|
646
|
+
headers: {
|
|
647
|
+
authorization: "Bearer " + encodedToken,
|
|
648
|
+
"x-organization-id": "org2", // In org2, user is guest
|
|
649
|
+
},
|
|
650
|
+
} as unknown as FlinkRequest;
|
|
651
|
+
|
|
652
|
+
// Should fail because user is only guest in org2
|
|
653
|
+
const authenticated = await plugin.authenticateRequest(mockRequest, ["delete"]);
|
|
654
|
+
|
|
655
|
+
expect(authenticated).toBeFalse();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("should use token roles when useDynamicRoles is false (default)", async () => {
|
|
659
|
+
const secret = "secret";
|
|
660
|
+
const userId = "123";
|
|
661
|
+
const encodedToken = jwtSimple.encode(
|
|
662
|
+
{ id: userId, roles: ["admin"] },
|
|
663
|
+
secret
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const plugin = jwtAuthPlugin({
|
|
667
|
+
secret,
|
|
668
|
+
// useDynamicRoles not set (defaults to false)
|
|
669
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
670
|
+
return {
|
|
671
|
+
id,
|
|
672
|
+
username: "testuser",
|
|
673
|
+
roles: ["guest"], // This should be ignored
|
|
674
|
+
};
|
|
675
|
+
},
|
|
676
|
+
rolePermissions: {
|
|
677
|
+
admin: ["read", "write", "delete"],
|
|
678
|
+
guest: ["read"],
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const mockRequest = {
|
|
683
|
+
headers: {
|
|
684
|
+
authorization: "Bearer " + encodedToken,
|
|
685
|
+
},
|
|
686
|
+
} as FlinkRequest;
|
|
687
|
+
|
|
688
|
+
// Should pass because token has admin role (user.roles ignored)
|
|
689
|
+
const authenticated = await plugin.authenticateRequest(mockRequest, ["delete"]);
|
|
690
|
+
|
|
691
|
+
expect(authenticated).toBeTruthy();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("should support multi-tenant scenario with organization context", async () => {
|
|
695
|
+
const secret = "secret";
|
|
696
|
+
const userId = "user123";
|
|
697
|
+
const encodedToken = jwtSimple.encode(
|
|
698
|
+
{ id: userId, roles: [] }, // No roles in token
|
|
699
|
+
secret
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// Simulate database of org memberships
|
|
703
|
+
const orgMemberships: { [key: string]: { [userId: string]: string } } = {
|
|
704
|
+
org1: { user123: "admin" },
|
|
705
|
+
org2: { user123: "user" },
|
|
706
|
+
org3: { user123: "guest" },
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const plugin = jwtAuthPlugin({
|
|
710
|
+
secret,
|
|
711
|
+
useDynamicRoles: true,
|
|
712
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
713
|
+
const orgId = req.headers["x-organization-id"] as string;
|
|
714
|
+
const role = orgMemberships[orgId]?.[id] || "guest";
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
id,
|
|
718
|
+
username: "multitenantuser",
|
|
719
|
+
organizationId: orgId,
|
|
720
|
+
roles: [role],
|
|
721
|
+
};
|
|
722
|
+
},
|
|
723
|
+
rolePermissions: {
|
|
724
|
+
admin: ["read", "write", "delete", "manage"],
|
|
725
|
+
user: ["read", "write"],
|
|
726
|
+
guest: ["read"],
|
|
727
|
+
},
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Test as admin in org1
|
|
731
|
+
const org1Request = {
|
|
732
|
+
headers: {
|
|
733
|
+
authorization: "Bearer " + encodedToken,
|
|
734
|
+
"x-organization-id": "org1",
|
|
735
|
+
},
|
|
736
|
+
} as unknown as FlinkRequest;
|
|
737
|
+
|
|
738
|
+
const org1Auth = await plugin.authenticateRequest(org1Request, ["manage"]);
|
|
739
|
+
expect(org1Auth).toBeTruthy();
|
|
740
|
+
expect(org1Request.user?.roles).toEqual(["admin"]);
|
|
741
|
+
|
|
742
|
+
// Test as user in org2
|
|
743
|
+
const org2Request = {
|
|
744
|
+
headers: {
|
|
745
|
+
authorization: "Bearer " + encodedToken,
|
|
746
|
+
"x-organization-id": "org2",
|
|
747
|
+
},
|
|
748
|
+
} as unknown as FlinkRequest;
|
|
749
|
+
|
|
750
|
+
const org2AuthWrite = await plugin.authenticateRequest(org2Request, ["write"]);
|
|
751
|
+
expect(org2AuthWrite).toBeTruthy();
|
|
752
|
+
|
|
753
|
+
const org2AuthManage = await plugin.authenticateRequest(org2Request, ["manage"]);
|
|
754
|
+
expect(org2AuthManage).toBeFalse(); // User can't manage in org2
|
|
755
|
+
|
|
756
|
+
// Test as guest in org3
|
|
757
|
+
const org3Request = {
|
|
758
|
+
headers: {
|
|
759
|
+
authorization: "Bearer " + encodedToken,
|
|
760
|
+
"x-organization-id": "org3",
|
|
761
|
+
},
|
|
762
|
+
} as unknown as FlinkRequest;
|
|
763
|
+
|
|
764
|
+
const org3AuthRead = await plugin.authenticateRequest(org3Request, ["read"]);
|
|
765
|
+
expect(org3AuthRead).toBeTruthy();
|
|
766
|
+
|
|
767
|
+
const org3AuthWrite = await plugin.authenticateRequest(org3Request, ["write"]);
|
|
768
|
+
expect(org3AuthWrite).toBeFalse(); // Guest can't write in org3
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("should have access to request properties in getUser callback", async () => {
|
|
772
|
+
const secret = "secret";
|
|
773
|
+
const userId = "123";
|
|
774
|
+
const encodedToken = jwtSimple.encode({ id: userId }, secret);
|
|
775
|
+
|
|
776
|
+
let capturedPath: string | undefined;
|
|
777
|
+
let capturedMethod: string | undefined;
|
|
778
|
+
let capturedHeaders: any;
|
|
779
|
+
|
|
780
|
+
const plugin = jwtAuthPlugin({
|
|
781
|
+
secret,
|
|
782
|
+
useDynamicRoles: true,
|
|
783
|
+
getUser: async ({ id }: { id: string }, req) => {
|
|
784
|
+
// Capture request properties to verify they're accessible
|
|
785
|
+
capturedPath = req.path;
|
|
786
|
+
capturedMethod = req.method;
|
|
787
|
+
capturedHeaders = req.headers;
|
|
788
|
+
|
|
789
|
+
return {
|
|
790
|
+
id,
|
|
791
|
+
username: "testuser",
|
|
792
|
+
roles: ["user"],
|
|
793
|
+
};
|
|
794
|
+
},
|
|
795
|
+
rolePermissions: {
|
|
796
|
+
user: ["read"],
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
const mockRequest = {
|
|
801
|
+
path: "/api/users",
|
|
802
|
+
method: "GET",
|
|
803
|
+
headers: {
|
|
804
|
+
authorization: "Bearer " + encodedToken,
|
|
805
|
+
"x-custom-header": "custom-value",
|
|
806
|
+
},
|
|
807
|
+
} as unknown as FlinkRequest;
|
|
808
|
+
|
|
809
|
+
await plugin.authenticateRequest(mockRequest, ["read"]);
|
|
810
|
+
|
|
811
|
+
expect(capturedPath).toBe("/api/users");
|
|
812
|
+
expect(capturedMethod).toBe("GET");
|
|
813
|
+
expect(capturedHeaders["x-custom-header"]).toBe("custom-value");
|
|
814
|
+
});
|
|
815
|
+
});
|
|
572
816
|
});
|
|
@@ -37,7 +37,7 @@ export type PermissionChecker = (
|
|
|
37
37
|
export interface JwtAuthPluginOptions {
|
|
38
38
|
secret: string;
|
|
39
39
|
algo?: jwtSimple.TAlgorithm;
|
|
40
|
-
getUser: (tokenData: any) => Promise<FlinkAuthUser | null | undefined>;
|
|
40
|
+
getUser: (tokenData: any, req: FlinkRequest) => Promise<FlinkAuthUser | null | undefined>;
|
|
41
41
|
passwordPolicy?: RegExp;
|
|
42
42
|
tokenTTL?: number;
|
|
43
43
|
rolePermissions: {
|
|
@@ -66,6 +66,27 @@ export interface JwtAuthPluginOptions {
|
|
|
66
66
|
* ```
|
|
67
67
|
*/
|
|
68
68
|
checkPermissions?: PermissionChecker;
|
|
69
|
+
/**
|
|
70
|
+
* When true, uses roles from the user object returned by getUser
|
|
71
|
+
* instead of roles from the decoded token for static permission checking.
|
|
72
|
+
*
|
|
73
|
+
* Useful for multi-tenant scenarios where user roles vary by organization context.
|
|
74
|
+
* The organization context can be determined from request headers, subdomain, path, etc.
|
|
75
|
+
*
|
|
76
|
+
* Example:
|
|
77
|
+
* ```typescript
|
|
78
|
+
* useDynamicRoles: true,
|
|
79
|
+
* getUser: async (tokenData, req) => {
|
|
80
|
+
* const orgId = req.headers['x-organization-id'];
|
|
81
|
+
* const membership = await getOrgMembership(tokenData.userId, orgId);
|
|
82
|
+
* return {
|
|
83
|
+
* id: tokenData.userId,
|
|
84
|
+
* roles: [membership.role], // Org-specific role
|
|
85
|
+
* };
|
|
86
|
+
* }
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
useDynamicRoles?: boolean;
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
@@ -105,6 +126,7 @@ export function jwtAuthPlugin({
|
|
|
105
126
|
tokenTTL = 1000 * 60 * 60 * 24 * 365 * 100, //Defaults to hundred year
|
|
106
127
|
tokenExtractor,
|
|
107
128
|
checkPermissions,
|
|
129
|
+
useDynamicRoles = false,
|
|
108
130
|
}: JwtAuthPluginOptions): JwtAuthPlugin {
|
|
109
131
|
return {
|
|
110
132
|
authenticateRequest: async (req, permissions) =>
|
|
@@ -114,6 +136,7 @@ export function jwtAuthPlugin({
|
|
|
114
136
|
getUser,
|
|
115
137
|
tokenExtractor,
|
|
116
138
|
checkPermissions,
|
|
139
|
+
useDynamicRoles,
|
|
117
140
|
}),
|
|
118
141
|
createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL }),
|
|
119
142
|
createPasswordHashAndSalt: (password: string) => createPasswordHashAndSalt(password, passwordPolicy),
|
|
@@ -125,9 +148,9 @@ async function authenticateRequest(
|
|
|
125
148
|
req: FlinkRequest,
|
|
126
149
|
routePermissions: string | string[],
|
|
127
150
|
rolePermissions: { [x: string]: string[] },
|
|
128
|
-
{ secret, algo, getUser, tokenExtractor, checkPermissions }: Pick<
|
|
151
|
+
{ secret, algo, getUser, tokenExtractor, checkPermissions, useDynamicRoles }: Pick<
|
|
129
152
|
JwtAuthPluginOptions,
|
|
130
|
-
"algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions"
|
|
153
|
+
"algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions" | "useDynamicRoles"
|
|
131
154
|
>
|
|
132
155
|
) {
|
|
133
156
|
let token: string | null | undefined;
|
|
@@ -159,8 +182,8 @@ async function authenticateRequest(
|
|
|
159
182
|
if (decodedToken) {
|
|
160
183
|
const permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
|
|
161
184
|
|
|
162
|
-
// Static permission check - only if custom checker NOT provided
|
|
163
|
-
if (!checkPermissions && permissionsArr && permissionsArr.length > 0) {
|
|
185
|
+
// Static permission check - only if custom checker NOT provided AND not using dynamic roles
|
|
186
|
+
if (!checkPermissions && !useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
|
|
164
187
|
const validPerms = hasValidPermissions(decodedToken.roles || [], rolePermissions, permissionsArr);
|
|
165
188
|
|
|
166
189
|
if (!validPerms) {
|
|
@@ -168,13 +191,23 @@ async function authenticateRequest(
|
|
|
168
191
|
}
|
|
169
192
|
}
|
|
170
193
|
|
|
171
|
-
const user = await getUser(decodedToken);
|
|
194
|
+
const user = await getUser(decodedToken, req);
|
|
172
195
|
|
|
173
196
|
if (!user) {
|
|
174
197
|
log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
|
|
175
198
|
return false;
|
|
176
199
|
}
|
|
177
200
|
|
|
201
|
+
// Dynamic roles: check permissions using roles from user object
|
|
202
|
+
if (!checkPermissions && useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
|
|
203
|
+
const validPerms = hasValidPermissions(user.roles || [], rolePermissions, permissionsArr);
|
|
204
|
+
|
|
205
|
+
if (!validPerms) {
|
|
206
|
+
log.debug("[JWT AUTH PLUGIN] Dynamic role permission check failed");
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
178
211
|
// Custom permission check - only if provided
|
|
179
212
|
if (checkPermissions && permissionsArr && permissionsArr.length > 0) {
|
|
180
213
|
const hasPermission = await checkPermissions(user, permissionsArr);
|