@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.
- package/dist/FlinkJwtAuthPlugin.d.ts +46 -2
- package/dist/FlinkJwtAuthPlugin.js +34 -8
- package/package.json +2 -2
- package/readme.md +364 -0
- package/spec/FlinkJwtAuthPlugin.spec.ts +443 -0
- package/src/FlinkJwtAuthPlugin.ts +83 -3
|
@@ -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,
|
|
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
|
-
|
|
93
|
-
|
|
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*/,
|
|
117
|
+
if (!decodedToken) return [3 /*break*/, 4];
|
|
103
118
|
permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
|
|
104
|
-
|
|
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
|
|
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.
|
|
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": "
|
|
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<
|
|
128
|
+
{ secret, algo, getUser, tokenExtractor, checkPermissions }: Pick<
|
|
129
|
+
JwtAuthPluginOptions,
|
|
130
|
+
"algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions"
|
|
131
|
+
>
|
|
77
132
|
) {
|
|
78
|
-
|
|
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
|
-
|
|
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
|
}
|