@flink-app/jwt-auth-plugin 0.12.1-alpha.40 → 0.12.1-alpha.41

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.
@@ -1,5 +1,25 @@
1
- import { FlinkAuthPlugin, FlinkAuthUser } from "@flink-app/flink";
1
+ import { FlinkAuthPlugin, FlinkAuthUser, FlinkRequest } from "@flink-app/flink";
2
2
  import jwtSimple from "jwt-simple";
3
+ /**
4
+ * Custom token extraction callback.
5
+ *
6
+ * Return values:
7
+ * - `string`: Token found, use this token
8
+ * - `null`: No token found, authentication should fail
9
+ * - `undefined`: Skip custom extraction, use default Bearer token extraction
10
+ */
11
+ export type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
12
+ /**
13
+ * Custom permission validation callback.
14
+ *
15
+ * Called after getUser to validate if the user has required permissions.
16
+ * Useful for dynamic permissions stored in database.
17
+ *
18
+ * @param user - The authenticated user object returned from getUser
19
+ * @param routePermissions - Array of permissions required by the route
20
+ * @returns true if user has required permissions, false otherwise
21
+ */
22
+ export type PermissionChecker = (user: FlinkAuthUser, routePermissions: string[]) => Promise<boolean> | boolean;
3
23
  export interface JwtAuthPluginOptions {
4
24
  secret: string;
5
25
  algo?: jwtSimple.TAlgorithm;
@@ -9,6 +29,29 @@ export interface JwtAuthPluginOptions {
9
29
  rolePermissions: {
10
30
  [role: string]: string[];
11
31
  };
32
+ /**
33
+ * Optional custom token extraction callback.
34
+ *
35
+ * Allows conditional token extraction based on request properties (path, method, headers, etc.).
36
+ * Return `undefined` to fall back to default Bearer token extraction.
37
+ */
38
+ tokenExtractor?: TokenExtractor;
39
+ /**
40
+ * Optional custom permission checker for dynamic permissions.
41
+ *
42
+ * When provided, replaces static rolePermissions checking.
43
+ * Called after getUser with the full user object.
44
+ *
45
+ * Example:
46
+ * ```typescript
47
+ * checkPermissions: async (user, routePermissions) => {
48
+ * return routePermissions.every(perm =>
49
+ * user.permissions?.includes(perm)
50
+ * );
51
+ * }
52
+ * ```
53
+ */
54
+ checkPermissions?: PermissionChecker;
12
55
  }
13
56
  export interface JwtAuthPlugin extends FlinkAuthPlugin {
14
57
  /**
@@ -38,4 +81,5 @@ export interface JwtAuthPlugin extends FlinkAuthPlugin {
38
81
  /**
39
82
  * Configures and creates authentication plugin.
40
83
  */
41
- export declare function jwtAuthPlugin({ secret, getUser, rolePermissions, algo, passwordPolicy, tokenTTL, }: JwtAuthPluginOptions): JwtAuthPlugin;
84
+ export declare function jwtAuthPlugin({ secret, getUser, rolePermissions, algo, passwordPolicy, tokenTTL, //Defaults to hundred year
85
+ tokenExtractor, checkPermissions, }: JwtAuthPluginOptions): JwtAuthPlugin;
@@ -65,7 +65,8 @@ var defaultPasswordPolicy = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
65
65
  */
66
66
  function jwtAuthPlugin(_a) {
67
67
  var _this = this;
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;
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
70
  return {
70
71
  authenticateRequest: function (req, permissions) { return __awaiter(_this, void 0, void 0, function () {
71
72
  return __generator(this, function (_a) {
@@ -73,6 +74,8 @@ function jwtAuthPlugin(_a) {
73
74
  algo: algo,
74
75
  secret: secret,
75
76
  getUser: getUser,
77
+ tokenExtractor: tokenExtractor,
78
+ checkPermissions: checkPermissions,
76
79
  })];
77
80
  });
78
81
  }); },
@@ -84,13 +87,25 @@ function jwtAuthPlugin(_a) {
84
87
  exports.jwtAuthPlugin = jwtAuthPlugin;
85
88
  function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
86
89
  return __awaiter(this, arguments, void 0, function (req, routePermissions, rolePermissions, _b) {
87
- var token, decodedToken, permissionsArr, validPerms, user;
88
- var secret = _b.secret, algo = _b.algo, getUser = _b.getUser;
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;
89
92
  return __generator(this, function (_c) {
90
93
  switch (_c.label) {
91
94
  case 0:
92
- token = getTokenFromReq(req);
93
- if (!token) return [3 /*break*/, 2];
95
+ if (tokenExtractor) {
96
+ token = tokenExtractor(req);
97
+ // If tokenExtractor returns undefined, fall back to default
98
+ if (token === undefined) {
99
+ token = getTokenFromReq(req);
100
+ }
101
+ // If it returns null, token stays null (no default fallback)
102
+ // If it returns string, token is the string
103
+ }
104
+ else {
105
+ // No custom extractor, use default
106
+ token = getTokenFromReq(req);
107
+ }
108
+ if (!token) return [3 /*break*/, 4];
94
109
  decodedToken = void 0;
95
110
  try {
96
111
  decodedToken = jwt_simple_1.default.decode(token, secret, false, algo);
@@ -99,9 +114,10 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
99
114
  flink_1.log.debug("[JWT AUTH PLUGIN] Failed to decode token: ".concat(err));
100
115
  decodedToken = null;
101
116
  }
102
- if (!decodedToken) return [3 /*break*/, 2];
117
+ if (!decodedToken) return [3 /*break*/, 4];
103
118
  permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
104
- if (permissionsArr && permissionsArr.length > 0) {
119
+ // Static permission check - only if custom checker NOT provided
120
+ if (!checkPermissions && permissionsArr && permissionsArr.length > 0) {
105
121
  validPerms = (0, PermissionValidator_1.hasValidPermissions)(decodedToken.roles || [], rolePermissions, permissionsArr);
106
122
  if (!validPerms) {
107
123
  return [2 /*return*/, false];
@@ -114,9 +130,19 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
114
130
  flink_1.log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
115
131
  return [2 /*return*/, false];
116
132
  }
133
+ if (!(checkPermissions && permissionsArr && permissionsArr.length > 0)) return [3 /*break*/, 3];
134
+ return [4 /*yield*/, checkPermissions(user, permissionsArr)];
135
+ case 2:
136
+ hasPermission = _c.sent();
137
+ if (!hasPermission) {
138
+ flink_1.log.debug("[JWT AUTH PLUGIN] Custom permission check failed");
139
+ return [2 /*return*/, false];
140
+ }
141
+ _c.label = 3;
142
+ case 3:
117
143
  req.user = user;
118
144
  return [2 /*return*/, true];
119
- case 2: return [2 /*return*/, false];
145
+ case 4: return [2 /*return*/, false];
120
146
  }
121
147
  });
122
148
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/jwt-auth-plugin",
3
- "version": "0.12.1-alpha.40",
3
+ "version": "0.12.1-alpha.41",
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",
@@ -31,5 +31,5 @@
31
31
  "tsc-watch": "^4.2.9",
32
32
  "typescript": "5.4.5"
33
33
  },
34
- "gitHead": "456502f273fe9473df05b71a803f3eda1a2f8931"
34
+ "gitHead": "76b54ee31f2c10c8c8f18af91facf5322b14ebf5"
35
35
  }
package/readme.md CHANGED
@@ -10,6 +10,8 @@ A Flink authentication plugin that provides JWT (JSON Web Token) based authentic
10
10
  - Configurable password policies
11
11
  - Token expiration support
12
12
  - Bearer token authentication
13
+ - Custom token extraction (query params, cookies, custom headers)
14
+ - Dynamic permission checking (database-backed permissions)
13
15
 
14
16
  ## Installation
15
17
 
@@ -69,6 +71,8 @@ start();
69
71
  | `algo` | `jwtSimple.TAlgorithm` | No | `"HS256"` | JWT signing algorithm |
70
72
  | `passwordPolicy` | `RegExp` | No | `/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/` | Regex to validate password strength |
71
73
  | `tokenTTL` | `number` | No | `1000 * 60 * 60 * 24 * 365 * 100` (100 years) | Token time-to-live in milliseconds |
74
+ | `tokenExtractor` | `(req: FlinkRequest) => string \| null \| undefined` | No | - | Custom token extraction function. Return `string` for token, `null` for no token, `undefined` to fallback to Bearer |
75
+ | `checkPermissions` | `(user: FlinkAuthUser, routePermissions: string[]) => Promise<boolean> \| boolean` | No | - | Custom permission validator for dynamic permissions from database. Replaces static `rolePermissions` when provided |
72
76
 
73
77
  ### Default Password Policy
74
78
 
@@ -89,6 +93,355 @@ jwtAuthPlugin({
89
93
  })
90
94
  ```
91
95
 
96
+ ## Custom Token Extraction
97
+
98
+ By default, the plugin extracts JWT tokens from the `Authorization` header as Bearer tokens:
99
+
100
+ ```
101
+ Authorization: Bearer <token>
102
+ ```
103
+
104
+ However, you can customize token extraction using the `tokenExtractor` option. This is useful for:
105
+ - Mobile apps that pass tokens in query parameters
106
+ - Cookie-based authentication for web routes
107
+ - Custom header schemes for specific endpoints
108
+ - Different auth methods for different route patterns
109
+
110
+ ### Token Extractor Return Values
111
+
112
+ The `tokenExtractor` callback supports three return values:
113
+
114
+ - **`string`**: Token found, use this token for authentication
115
+ - **`null`**: No token found, authentication should fail (no fallback to default Bearer)
116
+ - **`undefined`**: Skip custom extraction, use default Bearer token extraction
117
+
118
+ ### Example: Query Parameter for Public API Routes
119
+
120
+ ```typescript
121
+ jwtAuthPlugin({
122
+ secret: process.env.JWT_SECRET!,
123
+ getUser: async (tokenData) => { /* ... */ },
124
+ rolePermissions: { /* ... */ },
125
+ tokenExtractor: (req) => {
126
+ // Allow query param tokens only for public API routes
127
+ if (req.path?.startsWith('/api/public/') && req.method === 'GET') {
128
+ return req.query?.token as string || null;
129
+ }
130
+ // All other routes use default Bearer token
131
+ return undefined;
132
+ }
133
+ })
134
+ ```
135
+
136
+ **Usage:**
137
+ ```
138
+ GET /api/public/data?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
139
+ ```
140
+
141
+ ### Example: Cookie-Based Auth for Web, Bearer for API
142
+
143
+ ```typescript
144
+ jwtAuthPlugin({
145
+ secret: process.env.JWT_SECRET!,
146
+ getUser: async (tokenData) => { /* ... */ },
147
+ rolePermissions: { /* ... */ },
148
+ tokenExtractor: (req) => {
149
+ // Web routes use session cookie
150
+ if (req.path?.startsWith('/web/')) {
151
+ return req.cookies?.session_token || null;
152
+ }
153
+ // API routes use Bearer token (default)
154
+ return undefined;
155
+ }
156
+ })
157
+ ```
158
+
159
+ ### Example: Custom Header for Webhooks
160
+
161
+ ```typescript
162
+ jwtAuthPlugin({
163
+ secret: process.env.JWT_SECRET!,
164
+ getUser: async (tokenData) => { /* ... */ },
165
+ rolePermissions: { /* ... */ },
166
+ tokenExtractor: (req) => {
167
+ // Webhook endpoints use custom header
168
+ if (req.path?.startsWith('/webhooks/')) {
169
+ return req.headers['x-webhook-signature'] as string || null;
170
+ }
171
+ // Other routes use Bearer token
172
+ return undefined;
173
+ }
174
+ })
175
+ ```
176
+
177
+ ### Example: Method-Based Extraction
178
+
179
+ ```typescript
180
+ jwtAuthPlugin({
181
+ secret: process.env.JWT_SECRET!,
182
+ getUser: async (tokenData) => { /* ... */ },
183
+ rolePermissions: { /* ... */ },
184
+ tokenExtractor: (req) => {
185
+ // Special handling for PATCH requests
186
+ if (req.method === 'PATCH') {
187
+ return req.headers['x-patch-token'] as string || null;
188
+ }
189
+ // All other methods use Bearer
190
+ return undefined;
191
+ }
192
+ })
193
+ ```
194
+
195
+ ### Example: Multiple Fallbacks
196
+
197
+ ```typescript
198
+ jwtAuthPlugin({
199
+ secret: process.env.JWT_SECRET!,
200
+ getUser: async (tokenData) => { /* ... */ },
201
+ rolePermissions: { /* ... */ },
202
+ tokenExtractor: (req) => {
203
+ // Try cookie first for browser requests
204
+ if (req.headers['user-agent']?.includes('Mozilla')) {
205
+ const cookieToken = req.cookies?.auth_token;
206
+ if (cookieToken) return cookieToken;
207
+ }
208
+
209
+ // Try query param for mobile apps
210
+ if (req.query?.token) {
211
+ return req.query.token as string;
212
+ }
213
+
214
+ // Fall back to default Bearer token extraction
215
+ return undefined;
216
+ }
217
+ })
218
+ ```
219
+
220
+ ### Important Notes
221
+
222
+ - When `tokenExtractor` returns `undefined`, the plugin falls back to extracting from `Authorization: Bearer <token>`
223
+ - When it returns `null`, authentication fails immediately (useful to enforce specific auth methods for certain routes)
224
+ - When it returns a `string`, that token is validated using the same JWT verification logic
225
+ - The callback has access to `req.path`, `req.method`, `req.headers`, `req.query`, `req.cookies`, etc.
226
+
227
+ ## Dynamic Permissions with Database
228
+
229
+ By default, the plugin uses static `rolePermissions` defined at configuration time. However, you can implement dynamic permissions that are fetched from the database on each request using the `checkPermissions` callback.
230
+
231
+ ### When to Use Dynamic Permissions
232
+
233
+ Use `checkPermissions` when:
234
+ - Permissions are stored in the database per user or per role
235
+ - Permissions can change without restarting the application
236
+ - Different organizations/tenants have different permission sets
237
+ - You need fine-grained, user-specific permissions
238
+
239
+ ### How It Works
240
+
241
+ When you provide `checkPermissions`:
242
+ 1. Token is extracted and decoded (same as before)
243
+ 2. Static `rolePermissions` check is **skipped**
244
+ 3. `getUser` is called - this is where you fetch permissions from DB
245
+ 4. `checkPermissions` is called with the user object and required route permissions
246
+ 5. If `checkPermissions` returns `true`, authentication succeeds
247
+
248
+ ### Basic Example
249
+
250
+ ```typescript
251
+ jwtAuthPlugin({
252
+ secret: process.env.JWT_SECRET!,
253
+
254
+ getUser: async (tokenData) => {
255
+ // Fetch user from database
256
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
257
+
258
+ // Fetch user's permissions from database
259
+ const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
260
+
261
+ return {
262
+ id: user._id,
263
+ username: user.username,
264
+ roles: user.roles,
265
+ permissions, // Attach permissions to user object
266
+ };
267
+ },
268
+
269
+ // rolePermissions can be empty when using dynamic permissions
270
+ rolePermissions: {},
271
+
272
+ // Custom permission checker
273
+ checkPermissions: async (user, routePermissions) => {
274
+ // User must have ALL required permissions
275
+ return routePermissions.every(perm =>
276
+ user.permissions?.includes(perm)
277
+ );
278
+ },
279
+ })
280
+ ```
281
+
282
+ ### Multi-Tenant Example
283
+
284
+ ```typescript
285
+ jwtAuthPlugin({
286
+ secret: process.env.JWT_SECRET!,
287
+
288
+ getUser: async (tokenData) => {
289
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
290
+
291
+ // Fetch permissions based on user's organization
292
+ const permissions = await ctx.repos.permissionRepo.getOrgPermissions(
293
+ user._id,
294
+ user.organizationId
295
+ );
296
+
297
+ return {
298
+ id: user._id,
299
+ username: user.username,
300
+ organizationId: user.organizationId,
301
+ permissions,
302
+ };
303
+ },
304
+
305
+ rolePermissions: {},
306
+
307
+ checkPermissions: async (user, routePermissions) => {
308
+ return routePermissions.every(perm =>
309
+ user.permissions?.includes(perm)
310
+ );
311
+ },
312
+ })
313
+ ```
314
+
315
+ ### Hybrid: Role-Based + User-Specific Permissions
316
+
317
+ ```typescript
318
+ jwtAuthPlugin({
319
+ secret: process.env.JWT_SECRET!,
320
+
321
+ getUser: async (tokenData) => {
322
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
323
+
324
+ // Get base permissions from roles
325
+ const rolePerms = await ctx.repos.roleRepo.getRolePermissions(user.roles);
326
+
327
+ // Get user-specific permission overrides
328
+ const userPerms = await ctx.repos.permissionRepo.getUserPermissions(user._id);
329
+
330
+ // Combine both
331
+ const allPermissions = [...new Set([...rolePerms, ...userPerms])];
332
+
333
+ return {
334
+ id: user._id,
335
+ username: user.username,
336
+ roles: user.roles,
337
+ permissions: allPermissions,
338
+ };
339
+ },
340
+
341
+ rolePermissions: {},
342
+
343
+ checkPermissions: async (user, routePermissions) => {
344
+ return routePermissions.every(perm =>
345
+ user.permissions?.includes(perm)
346
+ );
347
+ },
348
+ })
349
+ ```
350
+
351
+ ### Permission with Wildcards
352
+
353
+ ```typescript
354
+ checkPermissions: async (user, routePermissions) => {
355
+ // Support wildcard permissions
356
+ if (user.permissions?.includes("*")) {
357
+ return true; // User has all permissions
358
+ }
359
+
360
+ // Check specific permissions
361
+ return routePermissions.every(perm =>
362
+ user.permissions?.includes(perm)
363
+ );
364
+ }
365
+ ```
366
+
367
+ ### Permission with OR Logic
368
+
369
+ ```typescript
370
+ checkPermissions: async (user, routePermissions) => {
371
+ // User needs ANY of the route permissions (OR logic)
372
+ return routePermissions.some(perm =>
373
+ user.permissions?.includes(perm)
374
+ );
375
+ }
376
+ ```
377
+
378
+ ### Caching Permissions for Performance
379
+
380
+ To reduce database load, you can cache permissions:
381
+
382
+ ```typescript
383
+ const permissionCache = new Map();
384
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
385
+
386
+ jwtAuthPlugin({
387
+ secret: process.env.JWT_SECRET!,
388
+
389
+ getUser: async (tokenData) => {
390
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
391
+
392
+ // Check cache first
393
+ const cacheKey = `perms:${user._id}`;
394
+ const cached = permissionCache.get(cacheKey);
395
+
396
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
397
+ return {
398
+ id: user._id,
399
+ username: user.username,
400
+ permissions: cached.permissions,
401
+ };
402
+ }
403
+
404
+ // Fetch from DB
405
+ const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
406
+
407
+ // Cache it
408
+ permissionCache.set(cacheKey, {
409
+ permissions,
410
+ timestamp: Date.now(),
411
+ });
412
+
413
+ return {
414
+ id: user._id,
415
+ username: user.username,
416
+ permissions,
417
+ };
418
+ },
419
+
420
+ rolePermissions: {},
421
+ checkPermissions: async (user, routePermissions) => {
422
+ return routePermissions.every(perm => user.permissions?.includes(perm));
423
+ },
424
+ })
425
+ ```
426
+
427
+ ### Dynamic Permissions Notes
428
+
429
+ - **Backward Compatible**: If you don't provide `checkPermissions`, static `rolePermissions` are used (existing behavior)
430
+ - **Performance**: `checkPermissions` is called on every authenticated request, so ensure `getUser` is optimized (consider caching)
431
+ - **User Object**: The `user` parameter in `checkPermissions` is the exact object returned from `getUser`
432
+ - **Public Routes**: If a route has no permissions (`[]`), `checkPermissions` is NOT called
433
+ - **Sync or Async**: `checkPermissions` can return `Promise<boolean>` or `boolean`
434
+
435
+ ### Troubleshooting Dynamic Permissions
436
+
437
+ **Issue**: Too many database queries
438
+
439
+ **Solution**: Implement permission caching in `getUser` or use an in-memory cache like Redis
440
+
441
+ **Issue**: Permissions not updating after database change
442
+
443
+ **Solution**: Clear permission cache or reduce cache TTL
444
+
92
445
  ## Context API
93
446
 
94
447
  Once configured, the plugin provides the following methods via the `auth` context:
@@ -542,6 +895,15 @@ Implement rate limiting on authentication endpoints to prevent brute force attac
542
895
  ```typescript
543
896
  import { JwtAuthPlugin, JwtAuthPluginOptions } from "@flink-app/jwt-auth-plugin";
544
897
 
898
+ // Token extractor callback type
899
+ type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
900
+
901
+ // Permission checker callback type
902
+ type PermissionChecker = (
903
+ user: FlinkAuthUser,
904
+ routePermissions: string[]
905
+ ) => Promise<boolean> | boolean;
906
+
545
907
  // Plugin options
546
908
  interface JwtAuthPluginOptions {
547
909
  secret: string;
@@ -552,6 +914,8 @@ interface JwtAuthPluginOptions {
552
914
  rolePermissions: {
553
915
  [role: string]: string[];
554
916
  };
917
+ tokenExtractor?: TokenExtractor;
918
+ checkPermissions?: PermissionChecker;
555
919
  }
556
920
 
557
921
  // Plugin interface
@@ -126,4 +126,447 @@ describe("FlinkJwtAuthPlugin", () => {
126
126
 
127
127
  expect(decoded.id).toBe("123");
128
128
  });
129
+
130
+ describe("tokenExtractor", () => {
131
+ it("should use custom token extractor when it returns a string", async () => {
132
+ const secret = "secret";
133
+ const userId = "123";
134
+ const customToken = jwtSimple.encode(
135
+ { id: userId, roles: ["user"] },
136
+ secret
137
+ );
138
+
139
+ const plugin = jwtAuthPlugin({
140
+ secret,
141
+ getUser: async ({ id }: { id: string }) => {
142
+ expect(id).toBe(userId);
143
+ return {
144
+ id,
145
+ username: "username",
146
+ };
147
+ },
148
+ rolePermissions: {
149
+ user: ["*"],
150
+ },
151
+ tokenExtractor: (req) => {
152
+ // Extract from query param
153
+ return req.query?.token as string;
154
+ },
155
+ });
156
+
157
+ const mockRequest = {
158
+ headers: {},
159
+ query: {
160
+ token: customToken,
161
+ },
162
+ } as unknown as FlinkRequest;
163
+
164
+ const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
165
+
166
+ expect(authenticated).toBeTruthy();
167
+ });
168
+
169
+ it("should fail auth when tokenExtractor returns null", async () => {
170
+ const plugin = jwtAuthPlugin({
171
+ secret: "secret",
172
+ getUser: async (id: string) => {
173
+ fail(); // Should not be called
174
+ return {
175
+ id,
176
+ username: "username",
177
+ };
178
+ },
179
+ rolePermissions: {
180
+ user: ["*"],
181
+ },
182
+ tokenExtractor: (req) => {
183
+ // Explicitly no token for this route
184
+ return null;
185
+ },
186
+ });
187
+
188
+ const mockRequest = {
189
+ headers: {
190
+ authorization: "Bearer some-valid-token",
191
+ },
192
+ } as FlinkRequest;
193
+
194
+ const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
195
+
196
+ expect(authenticated).toBeFalse();
197
+ });
198
+
199
+ it("should fall back to Bearer token when tokenExtractor returns undefined", async () => {
200
+ const secret = "secret";
201
+ const userId = "123";
202
+ const encodedToken = jwtSimple.encode(
203
+ { id: userId, roles: ["user"] },
204
+ secret
205
+ );
206
+
207
+ const plugin = jwtAuthPlugin({
208
+ secret,
209
+ getUser: async ({ id }: { id: string }) => {
210
+ expect(id).toBe(userId);
211
+ return {
212
+ id,
213
+ username: "username",
214
+ };
215
+ },
216
+ rolePermissions: {
217
+ user: ["*"],
218
+ },
219
+ tokenExtractor: (req) => {
220
+ // Return undefined to fall back to default
221
+ return undefined;
222
+ },
223
+ });
224
+
225
+ const mockRequest = {
226
+ headers: {
227
+ authorization: "Bearer " + encodedToken,
228
+ },
229
+ } as FlinkRequest;
230
+
231
+ const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
232
+
233
+ expect(authenticated).toBeTruthy();
234
+ });
235
+
236
+ it("should support conditional extraction based on path", async () => {
237
+ const secret = "secret";
238
+ const userId = "123";
239
+ const queryToken = jwtSimple.encode(
240
+ { id: userId, roles: ["user"] },
241
+ secret
242
+ );
243
+ const bearerToken = jwtSimple.encode(
244
+ { id: userId, roles: ["user"] },
245
+ secret
246
+ );
247
+
248
+ const plugin = jwtAuthPlugin({
249
+ secret,
250
+ getUser: async ({ id }: { id: string }) => {
251
+ return {
252
+ id,
253
+ username: "username",
254
+ };
255
+ },
256
+ rolePermissions: {
257
+ user: ["*"],
258
+ },
259
+ tokenExtractor: (req) => {
260
+ // Use query param for public API routes only
261
+ if (req.path?.startsWith("/api/public/")) {
262
+ return req.query?.token as string || null;
263
+ }
264
+ // Fall back to Bearer for other routes
265
+ return undefined;
266
+ },
267
+ });
268
+
269
+ // Test public route with query param
270
+ const publicRequest = {
271
+ path: "/api/public/data",
272
+ headers: {},
273
+ query: {
274
+ token: queryToken,
275
+ },
276
+ } as unknown as FlinkRequest;
277
+
278
+ const publicAuth = await plugin.authenticateRequest(publicRequest, "foo");
279
+ expect(publicAuth).toBeTruthy();
280
+
281
+ // Test non-public route with Bearer token
282
+ const privateRequest = {
283
+ path: "/api/private/data",
284
+ headers: {
285
+ authorization: "Bearer " + bearerToken,
286
+ },
287
+ } as unknown as FlinkRequest;
288
+
289
+ const privateAuth = await plugin.authenticateRequest(privateRequest, "foo");
290
+ expect(privateAuth).toBeTruthy();
291
+ });
292
+
293
+ it("should use default Bearer extraction when no tokenExtractor provided", async () => {
294
+ const secret = "secret";
295
+ const userId = "123";
296
+ const encodedToken = jwtSimple.encode(
297
+ { id: userId, roles: ["user"] },
298
+ secret
299
+ );
300
+
301
+ const plugin = jwtAuthPlugin({
302
+ secret,
303
+ getUser: async ({ id }: { id: string }) => {
304
+ return {
305
+ id,
306
+ username: "username",
307
+ };
308
+ },
309
+ rolePermissions: {
310
+ user: ["*"],
311
+ },
312
+ // No tokenExtractor provided
313
+ });
314
+
315
+ const mockRequest = {
316
+ headers: {
317
+ authorization: "Bearer " + encodedToken,
318
+ },
319
+ } as FlinkRequest;
320
+
321
+ const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
322
+
323
+ expect(authenticated).toBeTruthy();
324
+ });
325
+ });
326
+
327
+ describe("checkPermissions callback", () => {
328
+ it("should use custom permission checker when provided", async () => {
329
+ const secret = "secret";
330
+ const userId = "123";
331
+ const encodedToken = jwtSimple.encode(
332
+ { id: userId, roles: ["user"] },
333
+ secret
334
+ );
335
+
336
+ const plugin = jwtAuthPlugin({
337
+ secret,
338
+ getUser: async ({ id }: { id: string }) => {
339
+ return {
340
+ id,
341
+ username: "testuser",
342
+ permissions: ["read", "write", "delete"],
343
+ };
344
+ },
345
+ rolePermissions: {
346
+ user: [], // Empty - custom checker will handle this
347
+ },
348
+ checkPermissions: async (user, routePermissions) => {
349
+ // Check if user has all required permissions
350
+ return routePermissions.every((perm) =>
351
+ user.permissions?.includes(perm)
352
+ );
353
+ },
354
+ });
355
+
356
+ const mockRequest = {
357
+ headers: {
358
+ authorization: "Bearer " + encodedToken,
359
+ },
360
+ } as FlinkRequest;
361
+
362
+ const authenticated = await plugin.authenticateRequest(mockRequest, [
363
+ "read",
364
+ "write",
365
+ ]);
366
+
367
+ expect(authenticated).toBeTruthy();
368
+ expect(mockRequest.user?.permissions).toEqual([
369
+ "read",
370
+ "write",
371
+ "delete",
372
+ ]);
373
+ });
374
+
375
+ it("should fail auth when custom checker returns false", async () => {
376
+ const secret = "secret";
377
+ const userId = "123";
378
+ const encodedToken = jwtSimple.encode(
379
+ { id: userId, roles: ["user"] },
380
+ secret
381
+ );
382
+
383
+ const plugin = jwtAuthPlugin({
384
+ secret,
385
+ getUser: async ({ id }: { id: string }) => {
386
+ return {
387
+ id,
388
+ username: "testuser",
389
+ permissions: ["read"], // Only has read
390
+ };
391
+ },
392
+ rolePermissions: {},
393
+ checkPermissions: async (user, routePermissions) => {
394
+ return routePermissions.every((perm) =>
395
+ user.permissions?.includes(perm)
396
+ );
397
+ },
398
+ });
399
+
400
+ const mockRequest = {
401
+ headers: {
402
+ authorization: "Bearer " + encodedToken,
403
+ },
404
+ } as FlinkRequest;
405
+
406
+ // Route requires write, but user only has read
407
+ const authenticated = await plugin.authenticateRequest(mockRequest, [
408
+ "read",
409
+ "write",
410
+ ]);
411
+
412
+ expect(authenticated).toBeFalse();
413
+ });
414
+
415
+ it("should use static rolePermissions when checkPermissions not provided (backward compat)", async () => {
416
+ const secret = "secret";
417
+ const userId = "123";
418
+ const encodedToken = jwtSimple.encode(
419
+ { id: userId, roles: ["admin"] },
420
+ secret
421
+ );
422
+
423
+ const plugin = jwtAuthPlugin({
424
+ secret,
425
+ getUser: async ({ id }: { id: string }) => {
426
+ return {
427
+ id,
428
+ username: "admin",
429
+ };
430
+ },
431
+ rolePermissions: {
432
+ admin: ["read", "write", "delete"],
433
+ },
434
+ // No checkPermissions provided - uses static
435
+ });
436
+
437
+ const mockRequest = {
438
+ headers: {
439
+ authorization: "Bearer " + encodedToken,
440
+ },
441
+ } as FlinkRequest;
442
+
443
+ const authenticated = await plugin.authenticateRequest(
444
+ mockRequest,
445
+ "write"
446
+ );
447
+
448
+ expect(authenticated).toBeTruthy();
449
+ });
450
+
451
+ it("should support synchronous permission checker", async () => {
452
+ const secret = "secret";
453
+ const userId = "123";
454
+ const encodedToken = jwtSimple.encode(
455
+ { id: userId, roles: ["user"] },
456
+ secret
457
+ );
458
+
459
+ const plugin = jwtAuthPlugin({
460
+ secret,
461
+ getUser: async ({ id }: { id: string }) => {
462
+ return {
463
+ id,
464
+ username: "testuser",
465
+ permissions: ["read"],
466
+ };
467
+ },
468
+ rolePermissions: {},
469
+ // Synchronous checker (not async)
470
+ checkPermissions: (user, routePermissions) => {
471
+ return routePermissions.every((perm) =>
472
+ user.permissions?.includes(perm)
473
+ );
474
+ },
475
+ });
476
+
477
+ const mockRequest = {
478
+ headers: {
479
+ authorization: "Bearer " + encodedToken,
480
+ },
481
+ } as FlinkRequest;
482
+
483
+ const authenticated = await plugin.authenticateRequest(
484
+ mockRequest,
485
+ "read"
486
+ );
487
+
488
+ expect(authenticated).toBeTruthy();
489
+ });
490
+
491
+ it("should pass when route has no permissions and custom checker provided", async () => {
492
+ const secret = "secret";
493
+ const userId = "123";
494
+ const encodedToken = jwtSimple.encode(
495
+ { id: userId, roles: ["user"] },
496
+ secret
497
+ );
498
+
499
+ let checkerCalled = false;
500
+
501
+ const plugin = jwtAuthPlugin({
502
+ secret,
503
+ getUser: async ({ id }: { id: string }) => {
504
+ return { id, username: "testuser" };
505
+ },
506
+ rolePermissions: {},
507
+ checkPermissions: async (user, routePermissions) => {
508
+ checkerCalled = true;
509
+ return true;
510
+ },
511
+ });
512
+
513
+ const mockRequest = {
514
+ headers: {
515
+ authorization: "Bearer " + encodedToken,
516
+ },
517
+ } as FlinkRequest;
518
+
519
+ // Empty permissions (public route)
520
+ const authenticated = await plugin.authenticateRequest(mockRequest, []);
521
+
522
+ expect(authenticated).toBeTruthy();
523
+ expect(checkerCalled).toBeFalse(); // Checker should not be called for public routes
524
+ });
525
+
526
+ it("should handle database-fetched permissions in getUser", async () => {
527
+ const secret = "secret";
528
+ const userId = "123";
529
+ const encodedToken = jwtSimple.encode(
530
+ { id: userId, roles: ["user"] },
531
+ secret
532
+ );
533
+
534
+ // Simulate DB permissions
535
+ const dbPermissions: { [key: string]: string[] } = {
536
+ "123": ["read", "write", "custom_permission"],
537
+ };
538
+
539
+ const plugin = jwtAuthPlugin({
540
+ secret,
541
+ getUser: async ({ id }: { id: string }) => {
542
+ // Simulate fetching permissions from DB
543
+ const permissions = dbPermissions[id] || [];
544
+ return {
545
+ id,
546
+ username: "testuser",
547
+ permissions,
548
+ };
549
+ },
550
+ rolePermissions: {},
551
+ checkPermissions: async (user, routePermissions) => {
552
+ return routePermissions.every((perm) =>
553
+ user.permissions?.includes(perm)
554
+ );
555
+ },
556
+ });
557
+
558
+ const mockRequest = {
559
+ headers: {
560
+ authorization: "Bearer " + encodedToken,
561
+ },
562
+ } as FlinkRequest;
563
+
564
+ const authenticated = await plugin.authenticateRequest(mockRequest, [
565
+ "custom_permission",
566
+ ]);
567
+
568
+ expect(authenticated).toBeTruthy();
569
+ expect(mockRequest.user?.permissions).toContain("custom_permission");
570
+ });
571
+ });
129
572
  });
@@ -9,6 +9,31 @@ import { hasValidPermissions } from "./PermissionValidator";
9
9
  */
10
10
  const defaultPasswordPolicy = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
11
11
 
12
+ /**
13
+ * Custom token extraction callback.
14
+ *
15
+ * Return values:
16
+ * - `string`: Token found, use this token
17
+ * - `null`: No token found, authentication should fail
18
+ * - `undefined`: Skip custom extraction, use default Bearer token extraction
19
+ */
20
+ export type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
21
+
22
+ /**
23
+ * Custom permission validation callback.
24
+ *
25
+ * Called after getUser to validate if the user has required permissions.
26
+ * Useful for dynamic permissions stored in database.
27
+ *
28
+ * @param user - The authenticated user object returned from getUser
29
+ * @param routePermissions - Array of permissions required by the route
30
+ * @returns true if user has required permissions, false otherwise
31
+ */
32
+ export type PermissionChecker = (
33
+ user: FlinkAuthUser,
34
+ routePermissions: string[]
35
+ ) => Promise<boolean> | boolean;
36
+
12
37
  export interface JwtAuthPluginOptions {
13
38
  secret: string;
14
39
  algo?: jwtSimple.TAlgorithm;
@@ -18,6 +43,29 @@ export interface JwtAuthPluginOptions {
18
43
  rolePermissions: {
19
44
  [role: string]: string[];
20
45
  };
46
+ /**
47
+ * Optional custom token extraction callback.
48
+ *
49
+ * Allows conditional token extraction based on request properties (path, method, headers, etc.).
50
+ * Return `undefined` to fall back to default Bearer token extraction.
51
+ */
52
+ tokenExtractor?: TokenExtractor;
53
+ /**
54
+ * Optional custom permission checker for dynamic permissions.
55
+ *
56
+ * When provided, replaces static rolePermissions checking.
57
+ * Called after getUser with the full user object.
58
+ *
59
+ * Example:
60
+ * ```typescript
61
+ * checkPermissions: async (user, routePermissions) => {
62
+ * return routePermissions.every(perm =>
63
+ * user.permissions?.includes(perm)
64
+ * );
65
+ * }
66
+ * ```
67
+ */
68
+ checkPermissions?: PermissionChecker;
21
69
  }
22
70
 
23
71
  export interface JwtAuthPlugin extends FlinkAuthPlugin {
@@ -55,6 +103,8 @@ export function jwtAuthPlugin({
55
103
  algo = "HS256",
56
104
  passwordPolicy = defaultPasswordPolicy,
57
105
  tokenTTL = 1000 * 60 * 60 * 24 * 365 * 100, //Defaults to hundred year
106
+ tokenExtractor,
107
+ checkPermissions,
58
108
  }: JwtAuthPluginOptions): JwtAuthPlugin {
59
109
  return {
60
110
  authenticateRequest: async (req, permissions) =>
@@ -62,6 +112,8 @@ export function jwtAuthPlugin({
62
112
  algo,
63
113
  secret,
64
114
  getUser,
115
+ tokenExtractor,
116
+ checkPermissions,
65
117
  }),
66
118
  createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL }),
67
119
  createPasswordHashAndSalt: (password: string) => createPasswordHashAndSalt(password, passwordPolicy),
@@ -73,9 +125,26 @@ async function authenticateRequest(
73
125
  req: FlinkRequest,
74
126
  routePermissions: string | string[],
75
127
  rolePermissions: { [x: string]: string[] },
76
- { secret, algo, getUser }: Pick<JwtAuthPluginOptions, "algo" | "secret" | "getUser">
128
+ { secret, algo, getUser, tokenExtractor, checkPermissions }: Pick<
129
+ JwtAuthPluginOptions,
130
+ "algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions"
131
+ >
77
132
  ) {
78
- const token = getTokenFromReq(req);
133
+ let token: string | null | undefined;
134
+
135
+ if (tokenExtractor) {
136
+ token = tokenExtractor(req);
137
+
138
+ // If tokenExtractor returns undefined, fall back to default
139
+ if (token === undefined) {
140
+ token = getTokenFromReq(req);
141
+ }
142
+ // If it returns null, token stays null (no default fallback)
143
+ // If it returns string, token is the string
144
+ } else {
145
+ // No custom extractor, use default
146
+ token = getTokenFromReq(req);
147
+ }
79
148
 
80
149
  if (token) {
81
150
  let decodedToken;
@@ -90,7 +159,8 @@ async function authenticateRequest(
90
159
  if (decodedToken) {
91
160
  const permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
92
161
 
93
- if (permissionsArr && permissionsArr.length > 0) {
162
+ // Static permission check - only if custom checker NOT provided
163
+ if (!checkPermissions && permissionsArr && permissionsArr.length > 0) {
94
164
  const validPerms = hasValidPermissions(decodedToken.roles || [], rolePermissions, permissionsArr);
95
165
 
96
166
  if (!validPerms) {
@@ -105,6 +175,16 @@ async function authenticateRequest(
105
175
  return false;
106
176
  }
107
177
 
178
+ // Custom permission check - only if provided
179
+ if (checkPermissions && permissionsArr && permissionsArr.length > 0) {
180
+ const hasPermission = await checkPermissions(user, permissionsArr);
181
+
182
+ if (!hasPermission) {
183
+ log.debug("[JWT AUTH PLUGIN] Custom permission check failed");
184
+ return false;
185
+ }
186
+ }
187
+
108
188
  req.user = user;
109
189
  return true;
110
190
  }