@flink-app/jwt-auth-plugin 1.0.0 → 2.0.0-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +424 -0
- package/dist/FlinkJwtAuthPlugin.d.ts +36 -9
- package/dist/FlinkJwtAuthPlugin.js +64 -13
- package/dist/PermissionValidator.js +18 -1
- package/package.json +5 -2
- package/readme.md +314 -0
- package/spec/FlinkJwtAuthPlugin.spec.ts +162 -0
- package/spec/PermissionValidator.spec.ts +92 -0
- package/src/FlinkJwtAuthPlugin.ts +115 -15
- package/src/PermissionValidator.ts +18 -1
|
@@ -34,10 +34,29 @@ export type PermissionChecker = (
|
|
|
34
34
|
routePermissions: string[]
|
|
35
35
|
) => Promise<boolean> | boolean;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Custom token TTL resolver callback.
|
|
39
|
+
*
|
|
40
|
+
* Called during token creation to determine expiration dynamically based on roles.
|
|
41
|
+
*
|
|
42
|
+
* Return values:
|
|
43
|
+
* - `number`: TTL in milliseconds to use for this token
|
|
44
|
+
* - `undefined`: Fall back to static `tokenTTL` option (or default ~100 years)
|
|
45
|
+
*/
|
|
46
|
+
export type TokenTTLResolver = (roles: string[], payload: any) => number | undefined;
|
|
47
|
+
|
|
37
48
|
export interface JwtAuthPluginOptions {
|
|
38
49
|
secret: string;
|
|
39
50
|
algo?: jwtSimple.TAlgorithm;
|
|
40
|
-
|
|
51
|
+
/**
|
|
52
|
+
* @param ctx The host application's context, supplied by Flink core on
|
|
53
|
+
* each authenticated request. Lets the callback access repos/services
|
|
54
|
+
* that don't exist when the plugin is constructed (e.g. before
|
|
55
|
+
* `FlinkApp.start()` resolves) without forcing the consumer to keep a
|
|
56
|
+
* module-level mutable reference. Undefined only when invoked outside
|
|
57
|
+
* the normal request pipeline (e.g. unit tests).
|
|
58
|
+
*/
|
|
59
|
+
getUser: (tokenData: any, req: FlinkRequest, ctx?: any) => Promise<FlinkAuthUser | null | undefined>;
|
|
41
60
|
passwordPolicy?: RegExp;
|
|
42
61
|
tokenTTL?: number;
|
|
43
62
|
rolePermissions: {
|
|
@@ -87,17 +106,26 @@ export interface JwtAuthPluginOptions {
|
|
|
87
106
|
* ```
|
|
88
107
|
*/
|
|
89
108
|
useDynamicRoles?: boolean;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
93
109
|
/**
|
|
94
|
-
*
|
|
110
|
+
* Optional dynamic token TTL resolver.
|
|
95
111
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
112
|
+
* When provided, called during token creation with the user's roles and payload.
|
|
113
|
+
* Return a TTL in milliseconds to override the static `tokenTTL`, or `undefined`
|
|
114
|
+
* to fall back to `tokenTTL` (or the default).
|
|
115
|
+
*
|
|
116
|
+
* Example:
|
|
117
|
+
* ```typescript
|
|
118
|
+
* resolveTokenTTL: (roles) => {
|
|
119
|
+
* if (roles.includes("service-account")) return 1000 * 60 * 60 * 24 * 365; // 1 year
|
|
120
|
+
* if (roles.includes("admin")) return 1000 * 60 * 60 * 8; // 8 hours
|
|
121
|
+
* return 1000 * 60 * 60; // 1 hour default
|
|
122
|
+
* }
|
|
123
|
+
* ```
|
|
98
124
|
*/
|
|
99
|
-
|
|
125
|
+
resolveTokenTTL?: TokenTTLResolver;
|
|
126
|
+
}
|
|
100
127
|
|
|
128
|
+
export interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
101
129
|
/**
|
|
102
130
|
* Generates new password hash and salt for provided password.
|
|
103
131
|
*
|
|
@@ -123,14 +151,15 @@ export function jwtAuthPlugin({
|
|
|
123
151
|
rolePermissions,
|
|
124
152
|
algo = "HS256",
|
|
125
153
|
passwordPolicy = defaultPasswordPolicy,
|
|
126
|
-
tokenTTL
|
|
154
|
+
tokenTTL,
|
|
127
155
|
tokenExtractor,
|
|
128
156
|
checkPermissions,
|
|
129
157
|
useDynamicRoles = false,
|
|
158
|
+
resolveTokenTTL,
|
|
130
159
|
}: JwtAuthPluginOptions): JwtAuthPlugin {
|
|
131
160
|
return {
|
|
132
|
-
authenticateRequest: async (req, permissions) =>
|
|
133
|
-
authenticateRequest(req, permissions, rolePermissions, {
|
|
161
|
+
authenticateRequest: async (req, permissions, ctx) =>
|
|
162
|
+
authenticateRequest(req, permissions, rolePermissions, ctx, {
|
|
134
163
|
algo,
|
|
135
164
|
secret,
|
|
136
165
|
getUser,
|
|
@@ -138,7 +167,7 @@ export function jwtAuthPlugin({
|
|
|
138
167
|
checkPermissions,
|
|
139
168
|
useDynamicRoles,
|
|
140
169
|
}),
|
|
141
|
-
createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL }),
|
|
170
|
+
createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL, resolveTokenTTL }, roles),
|
|
142
171
|
createPasswordHashAndSalt: (password: string) => createPasswordHashAndSalt(password, passwordPolicy),
|
|
143
172
|
validatePassword,
|
|
144
173
|
};
|
|
@@ -148,6 +177,7 @@ async function authenticateRequest(
|
|
|
148
177
|
req: FlinkRequest,
|
|
149
178
|
routePermissions: string | string[],
|
|
150
179
|
rolePermissions: { [x: string]: string[] },
|
|
180
|
+
ctx: any,
|
|
151
181
|
{ secret, algo, getUser, tokenExtractor, checkPermissions, useDynamicRoles }: Pick<
|
|
152
182
|
JwtAuthPluginOptions,
|
|
153
183
|
"algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions" | "useDynamicRoles"
|
|
@@ -191,7 +221,7 @@ async function authenticateRequest(
|
|
|
191
221
|
}
|
|
192
222
|
}
|
|
193
223
|
|
|
194
|
-
const user = await getUser(decodedToken, req);
|
|
224
|
+
const user = await getUser(decodedToken, req, ctx);
|
|
195
225
|
|
|
196
226
|
if (!user) {
|
|
197
227
|
log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
|
|
@@ -219,6 +249,19 @@ async function authenticateRequest(
|
|
|
219
249
|
}
|
|
220
250
|
|
|
221
251
|
req.user = user;
|
|
252
|
+
|
|
253
|
+
// Expose the verified token so handlers can read claims (e.g. jti)
|
|
254
|
+
// without re-extracting and re-decoding (and without an unverified decode).
|
|
255
|
+
req.token = decodedToken;
|
|
256
|
+
req.rawToken = token;
|
|
257
|
+
|
|
258
|
+
// Populate userPermissions from resolved permissions
|
|
259
|
+
req.userPermissions = computeUserPermissions(user, decodedToken, {
|
|
260
|
+
useDynamicRoles,
|
|
261
|
+
rolePermissions,
|
|
262
|
+
checkPermissions,
|
|
263
|
+
});
|
|
264
|
+
|
|
222
265
|
return true;
|
|
223
266
|
}
|
|
224
267
|
}
|
|
@@ -234,12 +277,20 @@ function getTokenFromReq(req: FlinkRequest) {
|
|
|
234
277
|
return;
|
|
235
278
|
}
|
|
236
279
|
|
|
237
|
-
async function createToken(
|
|
280
|
+
async function createToken(
|
|
281
|
+
payload: any,
|
|
282
|
+
{ secret, algo, tokenTTL, resolveTokenTTL }: Pick<JwtAuthPluginOptions, "algo" | "secret" | "tokenTTL" | "resolveTokenTTL">,
|
|
283
|
+
roles: string[]
|
|
284
|
+
) {
|
|
238
285
|
if (!payload) {
|
|
239
286
|
throw new Error("Cannot create token - payload is missing");
|
|
240
287
|
}
|
|
241
288
|
|
|
242
|
-
|
|
289
|
+
const resolvedTTL = resolveTokenTTL?.(roles, payload);
|
|
290
|
+
const effectiveTTL = resolvedTTL ?? tokenTTL ?? 1000 * 60 * 60 * 24 * 365 * 100;
|
|
291
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
292
|
+
|
|
293
|
+
return jwtSimple.encode({ iat, exp: _calculateExpiration(effectiveTTL), ...payload }, secret, algo);
|
|
243
294
|
}
|
|
244
295
|
|
|
245
296
|
function _calculateExpiration(expiresInMs: number) {
|
|
@@ -261,3 +312,52 @@ async function validatePassword(password: string, passwordHash: string, salt: st
|
|
|
261
312
|
const hashCandidate = await encrypt(password, salt);
|
|
262
313
|
return hashCandidate === passwordHash;
|
|
263
314
|
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Compute user permissions based on auth plugin configuration
|
|
318
|
+
*
|
|
319
|
+
* Strategies (in order):
|
|
320
|
+
* 1. User object has permissions directly (from getUser callback)
|
|
321
|
+
* 2. Dynamic roles - map user.roles to permissions
|
|
322
|
+
* 3. Static roles from token - map token.roles to permissions
|
|
323
|
+
* 4. No permissions available - return empty array
|
|
324
|
+
*/
|
|
325
|
+
function computeUserPermissions(
|
|
326
|
+
user: any,
|
|
327
|
+
decodedToken: any,
|
|
328
|
+
config: {
|
|
329
|
+
useDynamicRoles?: boolean;
|
|
330
|
+
rolePermissions: { [x: string]: string[] };
|
|
331
|
+
checkPermissions?: PermissionChecker;
|
|
332
|
+
}
|
|
333
|
+
): string[] {
|
|
334
|
+
// Strategy 1: User object has permissions directly (from getUser callback)
|
|
335
|
+
if (user.permissions && Array.isArray(user.permissions)) {
|
|
336
|
+
return user.permissions;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Strategy 2: Dynamic roles - map user.roles to permissions
|
|
340
|
+
if (config.useDynamicRoles && user.roles && Array.isArray(user.roles)) {
|
|
341
|
+
return flattenRolesToPermissions(user.roles, config.rolePermissions);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Strategy 3: Static roles from token - map token.roles to permissions
|
|
345
|
+
if (decodedToken.roles && Array.isArray(decodedToken.roles)) {
|
|
346
|
+
return flattenRolesToPermissions(decodedToken.roles, config.rolePermissions);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Strategy 4: No permissions available
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Convert roles to permissions using rolePermissions mapping
|
|
355
|
+
*/
|
|
356
|
+
function flattenRolesToPermissions(roles: string[], roleMap: Record<string, string[]>): string[] {
|
|
357
|
+
const perms = new Set<string>();
|
|
358
|
+
for (const role of roles) {
|
|
359
|
+
const rolePerms = roleMap[role] || [];
|
|
360
|
+
rolePerms.forEach((p) => perms.add(p));
|
|
361
|
+
}
|
|
362
|
+
return Array.from(perms);
|
|
363
|
+
}
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { log } from "@flink-app/flink";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Checks whether a single role scope entry matches a required route
|
|
5
|
+
* permission. Supports exact match and trailing-segment wildcards of the
|
|
6
|
+
* form `"foo.bar.*"`, which match any route permission that starts with
|
|
7
|
+
* `"foo.bar."` (segment-scoped — does NOT match `"foo.bar"` itself, and
|
|
8
|
+
* does NOT match `"foo.barbaz.x"`).
|
|
9
|
+
*/
|
|
10
|
+
function scopeMatches(scope: string, routePerm: string): boolean {
|
|
11
|
+
if (scope === routePerm) return true;
|
|
12
|
+
if (scope.endsWith(".*")) {
|
|
13
|
+
// Keep the trailing "." so "a.*" does not match "a" or "ab.c".
|
|
14
|
+
const prefix = scope.slice(0, -1);
|
|
15
|
+
return routePerm.startsWith(prefix);
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
/**
|
|
4
21
|
* Checks if provided role has permission to access route
|
|
5
22
|
* based on its permissions.
|
|
@@ -36,7 +53,7 @@ export function hasValidPermissions(
|
|
|
36
53
|
|
|
37
54
|
if (
|
|
38
55
|
routePermissions.every((routePerm) =>
|
|
39
|
-
thisRolesPermissions.
|
|
56
|
+
thisRolesPermissions.some((scope) => scopeMatches(scope, routePerm))
|
|
40
57
|
)
|
|
41
58
|
) {
|
|
42
59
|
return true;
|