@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.
@@ -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.41",
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.40",
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": "76b54ee31f2c10c8c8f18af91facf5322b14ebf5"
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);