@flink-app/jwt-auth-plugin 0.14.1 → 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.
@@ -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
- getUser: (tokenData: any, req: FlinkRequest) => Promise<FlinkAuthUser | null | undefined>;
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
- * Encodes and returns JWT token that includes provided payload.
110
+ * Optional dynamic token TTL resolver.
95
111
  *
96
- * The payload can by anything but should in most cases be and object that
97
- * holds user information including an identifier such as the username or id.
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
- createToken: (payload: any, roles: string[]) => Promise<string>;
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 = 1000 * 60 * 60 * 24 * 365 * 100, //Defaults to hundred year
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(payload: any, { secret, algo, tokenTTL }: Pick<JwtAuthPluginOptions, "algo" | "secret" | "tokenTTL">) {
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
- return jwtSimple.encode({ exp: _calculateExpiration(tokenTTL || 1000 * 60 * 60 * 24 * 365 * 100), ...payload }, secret, algo);
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.includes(routePerm)
56
+ thisRolesPermissions.some((scope) => scopeMatches(scope, routePerm))
40
57
  )
41
58
  ) {
42
59
  return true;