@flink-app/jwt-auth-plugin 2.0.0-alpha.104 → 2.0.0-alpha.106

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # @flink-app/jwt-auth-plugin
2
2
 
3
+ ## 2.0.0-alpha.106
4
+
5
+ ### Patch Changes
6
+
7
+ - @flink-app/flink@2.0.0-alpha.106
8
+
9
+ ## 2.0.0-alpha.105
10
+
11
+ ### Minor Changes
12
+
13
+ - 6b11000: Custom auth-failure responses. `FlinkAuthPlugin.authenticateRequest` may now return a `FlinkResponse` (in addition to `boolean`) to replace the default `401 Unauthorized` — status (defaults to 401), error object, and headers (applied to the HTTP response, stripped from the JSON body, `reqId` injected). The jwt-auth-plugin exposes this via a new `onAuthFailure({ reason, error }, req, ctx)` option, called with a typed reason (`missing_token` | `invalid_token` | `no_user` | `permission_denied`; `invalid_token` carries the underlying decode error) — return a `FlinkResponse` to shape the failure or void to keep the 401. Fully backward compatible: existing plugins returning booleans behave unchanged.
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [6b11000]
18
+ - @flink-app/flink@2.0.0-alpha.105
19
+
3
20
  ## 2.0.0-alpha.104
4
21
 
5
22
  ### Patch Changes
@@ -1,4 +1,4 @@
1
- import { FlinkAuthPlugin, FlinkAuthUser, FlinkRequest } from "@flink-app/flink";
1
+ import { FlinkAuthPlugin, FlinkAuthUser, FlinkRequest, FlinkResponse } from "@flink-app/flink";
2
2
  import jwtSimple from "jwt-simple";
3
3
  /**
4
4
  * Custom token extraction callback.
@@ -20,6 +20,31 @@ export type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
20
20
  * @returns true if user has required permissions, false otherwise
21
21
  */
22
22
  export type PermissionChecker = (user: FlinkAuthUser, routePermissions: string[]) => Promise<boolean> | boolean;
23
+ /**
24
+ * Why an authentication attempt was rejected.
25
+ *
26
+ * - `missing_token`: no token found on the request (after tokenExtractor / Bearer fallback)
27
+ * - `invalid_token`: token could not be decoded/verified (expired, bad signature, malformed)
28
+ * - `no_user`: token was valid but `getUser` returned null/undefined (e.g. revoked session)
29
+ * - `permission_denied`: user is authenticated but lacks the route's permissions
30
+ */
31
+ export type AuthFailureReason = "missing_token" | "invalid_token" | "no_user" | "permission_denied";
32
+ export interface AuthFailure {
33
+ reason: AuthFailureReason;
34
+ /**
35
+ * The underlying error for `invalid_token` (whatever jwt decode threw, e.g.
36
+ * "Token expired" / "Signature verification failed"). Undefined otherwise.
37
+ */
38
+ error?: unknown;
39
+ }
40
+ /**
41
+ * Custom auth-failure response callback.
42
+ *
43
+ * Called whenever authentication is rejected. Return a `FlinkResponse` to have Flink
44
+ * send exactly that response (status, error object, headers such as `Set-Cookie`)
45
+ * instead of the default `401 Unauthorized`. Return void/undefined to keep the default.
46
+ */
47
+ export type AuthFailureHandler = (failure: AuthFailure, req: FlinkRequest, ctx?: any) => FlinkResponse | void | Promise<FlinkResponse | void>;
23
48
  /**
24
49
  * Custom token TTL resolver callback.
25
50
  *
@@ -91,6 +116,24 @@ export interface JwtAuthPluginOptions {
91
116
  * ```
92
117
  */
93
118
  useDynamicRoles?: boolean;
119
+ /**
120
+ * Optional custom auth-failure responses.
121
+ *
122
+ * Called with the failure reason whenever authentication is rejected
123
+ * (missing/invalid token, no user, permission denied). Return a `FlinkResponse`
124
+ * to replace the default `401 Unauthorized` — e.g. a 403 with a domain error code
125
+ * and a `Set-Cookie` header clearing a stale cookie. Return void to keep the 401.
126
+ *
127
+ * Example:
128
+ * ```typescript
129
+ * onAuthFailure: ({ reason }) => {
130
+ * if (reason === "permission_denied") {
131
+ * return { status: 403, error: { id: uuid(), code: "PERMISSION_DENIED", title: "Permission denied" } };
132
+ * }
133
+ * }
134
+ * ```
135
+ */
136
+ onAuthFailure?: AuthFailureHandler;
94
137
  /**
95
138
  * Optional dynamic token TTL resolver.
96
139
  *
@@ -130,4 +173,4 @@ export interface JwtAuthPlugin extends FlinkAuthPlugin {
130
173
  /**
131
174
  * Configures and creates authentication plugin.
132
175
  */
133
- export declare function jwtAuthPlugin({ secret, getUser, rolePermissions, algo, passwordPolicy, tokenTTL, tokenExtractor, checkPermissions, useDynamicRoles, resolveTokenTTL, }: JwtAuthPluginOptions): JwtAuthPlugin;
176
+ export declare function jwtAuthPlugin({ secret, getUser, rolePermissions, algo, passwordPolicy, tokenTTL, tokenExtractor, checkPermissions, useDynamicRoles, resolveTokenTTL, onAuthFailure, }: JwtAuthPluginOptions): JwtAuthPlugin;
@@ -65,7 +65,7 @@ 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, tokenTTL = _a.tokenTTL, tokenExtractor = _a.tokenExtractor, checkPermissions = _a.checkPermissions, _d = _a.useDynamicRoles, useDynamicRoles = _d === void 0 ? false : _d, resolveTokenTTL = _a.resolveTokenTTL;
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, tokenTTL = _a.tokenTTL, tokenExtractor = _a.tokenExtractor, checkPermissions = _a.checkPermissions, _d = _a.useDynamicRoles, useDynamicRoles = _d === void 0 ? false : _d, resolveTokenTTL = _a.resolveTokenTTL, onAuthFailure = _a.onAuthFailure;
69
69
  return {
70
70
  authenticateRequest: function (req, permissions, ctx) { return __awaiter(_this, void 0, void 0, function () {
71
71
  return __generator(this, function (_a) {
@@ -76,6 +76,7 @@ function jwtAuthPlugin(_a) {
76
76
  tokenExtractor: tokenExtractor,
77
77
  checkPermissions: checkPermissions,
78
78
  useDynamicRoles: useDynamicRoles,
79
+ onAuthFailure: onAuthFailure,
79
80
  })];
80
81
  });
81
82
  }); },
@@ -86,11 +87,29 @@ function jwtAuthPlugin(_a) {
86
87
  }
87
88
  function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, ctx_1, _a) {
88
89
  return __awaiter(this, arguments, void 0, function (req, routePermissions, rolePermissions, ctx, _b) {
89
- var token, decodedToken, permissionsArr, validPerms, user, validPerms, hasPermission;
90
- var secret = _b.secret, algo = _b.algo, getUser = _b.getUser, tokenExtractor = _b.tokenExtractor, checkPermissions = _b.checkPermissions, useDynamicRoles = _b.useDynamicRoles;
90
+ var fail, token, decodedToken, decodeError, permissionsArr, validPerms, user, validPerms, hasPermission;
91
+ var _this = this;
92
+ var secret = _b.secret, algo = _b.algo, getUser = _b.getUser, tokenExtractor = _b.tokenExtractor, checkPermissions = _b.checkPermissions, useDynamicRoles = _b.useDynamicRoles, onAuthFailure = _b.onAuthFailure;
91
93
  return __generator(this, function (_c) {
92
94
  switch (_c.label) {
93
95
  case 0:
96
+ fail = function (reason, error) { return __awaiter(_this, void 0, void 0, function () {
97
+ var failureRes;
98
+ return __generator(this, function (_a) {
99
+ switch (_a.label) {
100
+ case 0:
101
+ if (!onAuthFailure) return [3 /*break*/, 2];
102
+ return [4 /*yield*/, onAuthFailure({ reason: reason, error: error }, req, ctx)];
103
+ case 1:
104
+ failureRes = _a.sent();
105
+ if (failureRes) {
106
+ return [2 /*return*/, failureRes];
107
+ }
108
+ _a.label = 2;
109
+ case 2: return [2 /*return*/, false];
110
+ }
111
+ });
112
+ }); };
94
113
  if (tokenExtractor) {
95
114
  token = tokenExtractor(req);
96
115
  // If tokenExtractor returns undefined, fall back to default
@@ -104,22 +123,28 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, ctx_1
104
123
  // No custom extractor, use default
105
124
  token = getTokenFromReq(req);
106
125
  }
107
- if (!token) return [3 /*break*/, 4];
126
+ if (!token) {
127
+ return [2 /*return*/, fail("missing_token")];
128
+ }
108
129
  decodedToken = void 0;
130
+ decodeError = void 0;
109
131
  try {
110
132
  decodedToken = jwt_simple_1.default.decode(token, secret, false, algo);
111
133
  }
112
134
  catch (err) {
113
135
  flink_1.log.debug("[JWT AUTH PLUGIN] Failed to decode token: ".concat(err));
136
+ decodeError = err;
114
137
  decodedToken = null;
115
138
  }
116
- if (!decodedToken) return [3 /*break*/, 4];
139
+ if (!decodedToken) {
140
+ return [2 /*return*/, fail("invalid_token", decodeError)];
141
+ }
117
142
  permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
118
143
  // Static permission check - only if custom checker NOT provided AND not using dynamic roles
119
144
  if (!checkPermissions && !useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
120
145
  validPerms = (0, PermissionValidator_1.hasValidPermissions)(decodedToken.roles || [], rolePermissions, permissionsArr);
121
146
  if (!validPerms) {
122
- return [2 /*return*/, false];
147
+ return [2 /*return*/, fail("permission_denied")];
123
148
  }
124
149
  }
125
150
  return [4 /*yield*/, getUser(decodedToken, req, ctx)];
@@ -127,14 +152,14 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, ctx_1
127
152
  user = _c.sent();
128
153
  if (!user) {
129
154
  flink_1.log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
130
- return [2 /*return*/, false];
155
+ return [2 /*return*/, fail("no_user")];
131
156
  }
132
157
  // Dynamic roles: check permissions using roles from user object
133
158
  if (!checkPermissions && useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
134
159
  validPerms = (0, PermissionValidator_1.hasValidPermissions)(user.roles || [], rolePermissions, permissionsArr);
135
160
  if (!validPerms) {
136
161
  flink_1.log.debug("[JWT AUTH PLUGIN] Dynamic role permission check failed");
137
- return [2 /*return*/, false];
162
+ return [2 /*return*/, fail("permission_denied")];
138
163
  }
139
164
  }
140
165
  if (!(checkPermissions && permissionsArr && permissionsArr.length > 0)) return [3 /*break*/, 3];
@@ -143,7 +168,7 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, ctx_1
143
168
  hasPermission = _c.sent();
144
169
  if (!hasPermission) {
145
170
  flink_1.log.debug("[JWT AUTH PLUGIN] Custom permission check failed");
146
- return [2 /*return*/, false];
171
+ return [2 /*return*/, fail("permission_denied")];
147
172
  }
148
173
  _c.label = 3;
149
174
  case 3:
@@ -159,7 +184,6 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, ctx_1
159
184
  checkPermissions: checkPermissions,
160
185
  });
161
186
  return [2 /*return*/, true];
162
- case 4: return [2 /*return*/, false];
163
187
  }
164
188
  });
165
189
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/jwt-auth-plugin",
3
- "version": "2.0.0-alpha.104",
3
+ "version": "2.0.0-alpha.106",
4
4
  "description": "Flink plugin for JWT auth",
5
5
  "author": "joel@frost.se",
6
6
  "license": "MIT",
@@ -19,10 +19,10 @@
19
19
  "@types/node": "22.13.10",
20
20
  "ts-node": "^10.9.2",
21
21
  "tsc-watch": "^4.2.9",
22
- "@flink-app/flink": "2.0.0-alpha.104"
22
+ "@flink-app/flink": "2.0.0-alpha.106"
23
23
  },
24
24
  "peerDependencies": {
25
- "@flink-app/flink": ">=2.0.0-alpha.104"
25
+ "@flink-app/flink": ">=2.0.0-alpha.106"
26
26
  },
27
27
  "gitHead": "4243e3b3cd6d4e1ca001a61baa8436bf2bbe4113",
28
28
  "scripts": {
@@ -976,3 +976,124 @@ describe("FlinkJwtAuthPlugin", () => {
976
976
  });
977
977
  });
978
978
  });
979
+
980
+ describe("FlinkJwtAuthPlugin onAuthFailure", () => {
981
+ const secret = "secret";
982
+
983
+ const makePlugin = (
984
+ onAuthFailure: any,
985
+ overrides: any = {}
986
+ ) =>
987
+ jwtAuthPlugin({
988
+ secret,
989
+ getUser: async ({ id }: { id: string }) => ({ id, username: "username" }),
990
+ rolePermissions: { user: ["foo"] },
991
+ onAuthFailure,
992
+ ...overrides,
993
+ });
994
+
995
+ const reqWith = (authorization: string) =>
996
+ ({ headers: { authorization } } as FlinkRequest);
997
+
998
+ it("reports missing_token and passes the returned FlinkResponse through", async () => {
999
+ const failures: any[] = [];
1000
+ const plugin = makePlugin((failure: any) => {
1001
+ failures.push(failure);
1002
+ return { status: 403, error: { id: "1", code: "MUST_BE_LOGGED_IN", title: "Permission denied" } };
1003
+ });
1004
+
1005
+ const res = await plugin.authenticateRequest(reqWith(""), "foo");
1006
+
1007
+ expect(failures).toEqual([{ reason: "missing_token", error: undefined }]);
1008
+ expect(res).toEqual({ status: 403, error: { id: "1", code: "MUST_BE_LOGGED_IN", title: "Permission denied" } } as any);
1009
+ });
1010
+
1011
+ it("reports invalid_token with the underlying decode error", async () => {
1012
+ const failures: any[] = [];
1013
+ const plugin = makePlugin((failure: any) => {
1014
+ failures.push(failure);
1015
+ return { status: 403, error: { id: "2", code: "auth-service.403.1", title: "Invalid JWT token" } };
1016
+ });
1017
+
1018
+ const expired = jwtSimple.encode({ id: "123", roles: ["user"], exp: Date.now() / 1000 - 60 }, secret);
1019
+ const res: any = await plugin.authenticateRequest(reqWith("Bearer " + expired), "foo");
1020
+
1021
+ expect(failures.length).toBe(1);
1022
+ expect(failures[0].reason).toBe("invalid_token");
1023
+ expect(String(failures[0].error)).toContain("Token expired");
1024
+ expect(res.status).toBe(403);
1025
+ });
1026
+
1027
+ it("reports no_user when getUser returns null", async () => {
1028
+ const failures: any[] = [];
1029
+ const plugin = jwtAuthPlugin({
1030
+ secret,
1031
+ getUser: async () => null,
1032
+ rolePermissions: { user: ["foo"] },
1033
+ onAuthFailure: (failure) => {
1034
+ failures.push(failure);
1035
+ },
1036
+ });
1037
+
1038
+ const token = jwtSimple.encode({ id: "123", roles: ["user"] }, secret);
1039
+ const res = await plugin.authenticateRequest(reqWith("Bearer " + token), "foo");
1040
+
1041
+ expect(failures.map((f) => f.reason)).toEqual(["no_user"]);
1042
+ // Callback returned void -> default behaviour (false -> 401 in core)
1043
+ expect(res).toBeFalse();
1044
+ });
1045
+
1046
+ it("reports permission_denied from the static role check", async () => {
1047
+ const failures: any[] = [];
1048
+ const plugin = makePlugin((failure: any) => {
1049
+ failures.push(failure);
1050
+ });
1051
+
1052
+ const token = jwtSimple.encode({ id: "123", roles: ["user"] }, secret);
1053
+ const res = await plugin.authenticateRequest(reqWith("Bearer " + token), "bar");
1054
+
1055
+ expect(failures.map((f) => f.reason)).toEqual(["permission_denied"]);
1056
+ expect(res).toBeFalse();
1057
+ });
1058
+
1059
+ it("reports permission_denied from a custom checkPermissions", async () => {
1060
+ const failures: any[] = [];
1061
+ const plugin = makePlugin(
1062
+ (failure: any) => {
1063
+ failures.push(failure);
1064
+ return { status: 403, error: { id: "3", code: "PERMISSION_DENIED", title: "Permission denied" } };
1065
+ },
1066
+ { checkPermissions: async () => false }
1067
+ );
1068
+
1069
+ const token = jwtSimple.encode({ id: "123", roles: ["user"] }, secret);
1070
+ const res: any = await plugin.authenticateRequest(reqWith("Bearer " + token), "foo");
1071
+
1072
+ expect(failures.map((f) => f.reason)).toEqual(["permission_denied"]);
1073
+ expect(res.error.code).toBe("PERMISSION_DENIED");
1074
+ });
1075
+
1076
+ it("keeps returning plain false when no onAuthFailure is configured", async () => {
1077
+ const plugin = jwtAuthPlugin({
1078
+ secret,
1079
+ getUser: async ({ id }: { id: string }) => ({ id, username: "username" }),
1080
+ rolePermissions: { user: ["foo"] },
1081
+ });
1082
+
1083
+ expect(await plugin.authenticateRequest(reqWith(""), "foo")).toBeFalse();
1084
+ expect(await plugin.authenticateRequest(reqWith("Bearer garbage"), "foo")).toBeFalse();
1085
+ });
1086
+
1087
+ it("does not invoke onAuthFailure on successful auth", async () => {
1088
+ const failures: any[] = [];
1089
+ const plugin = makePlugin((failure: any) => {
1090
+ failures.push(failure);
1091
+ });
1092
+
1093
+ const token = jwtSimple.encode({ id: "123", roles: ["user"] }, secret);
1094
+ const res = await plugin.authenticateRequest(reqWith("Bearer " + token), "foo");
1095
+
1096
+ expect(res).toBeTrue();
1097
+ expect(failures).toEqual([]);
1098
+ });
1099
+ });
@@ -1,4 +1,4 @@
1
- import { FlinkAuthPlugin, FlinkAuthUser, FlinkRequest, log } from "@flink-app/flink";
1
+ import { FlinkAuthPlugin, FlinkAuthUser, FlinkRequest, FlinkResponse, log } from "@flink-app/flink";
2
2
  import jwtSimple from "jwt-simple";
3
3
  import { encrypt, genSalt } from "./BcryptUtils";
4
4
  import { hasValidPermissions } from "./PermissionValidator";
@@ -34,6 +34,38 @@ export type PermissionChecker = (
34
34
  routePermissions: string[]
35
35
  ) => Promise<boolean> | boolean;
36
36
 
37
+ /**
38
+ * Why an authentication attempt was rejected.
39
+ *
40
+ * - `missing_token`: no token found on the request (after tokenExtractor / Bearer fallback)
41
+ * - `invalid_token`: token could not be decoded/verified (expired, bad signature, malformed)
42
+ * - `no_user`: token was valid but `getUser` returned null/undefined (e.g. revoked session)
43
+ * - `permission_denied`: user is authenticated but lacks the route's permissions
44
+ */
45
+ export type AuthFailureReason = "missing_token" | "invalid_token" | "no_user" | "permission_denied";
46
+
47
+ export interface AuthFailure {
48
+ reason: AuthFailureReason;
49
+ /**
50
+ * The underlying error for `invalid_token` (whatever jwt decode threw, e.g.
51
+ * "Token expired" / "Signature verification failed"). Undefined otherwise.
52
+ */
53
+ error?: unknown;
54
+ }
55
+
56
+ /**
57
+ * Custom auth-failure response callback.
58
+ *
59
+ * Called whenever authentication is rejected. Return a `FlinkResponse` to have Flink
60
+ * send exactly that response (status, error object, headers such as `Set-Cookie`)
61
+ * instead of the default `401 Unauthorized`. Return void/undefined to keep the default.
62
+ */
63
+ export type AuthFailureHandler = (
64
+ failure: AuthFailure,
65
+ req: FlinkRequest,
66
+ ctx?: any
67
+ ) => FlinkResponse | void | Promise<FlinkResponse | void>;
68
+
37
69
  /**
38
70
  * Custom token TTL resolver callback.
39
71
  *
@@ -106,6 +138,24 @@ export interface JwtAuthPluginOptions {
106
138
  * ```
107
139
  */
108
140
  useDynamicRoles?: boolean;
141
+ /**
142
+ * Optional custom auth-failure responses.
143
+ *
144
+ * Called with the failure reason whenever authentication is rejected
145
+ * (missing/invalid token, no user, permission denied). Return a `FlinkResponse`
146
+ * to replace the default `401 Unauthorized` — e.g. a 403 with a domain error code
147
+ * and a `Set-Cookie` header clearing a stale cookie. Return void to keep the 401.
148
+ *
149
+ * Example:
150
+ * ```typescript
151
+ * onAuthFailure: ({ reason }) => {
152
+ * if (reason === "permission_denied") {
153
+ * return { status: 403, error: { id: uuid(), code: "PERMISSION_DENIED", title: "Permission denied" } };
154
+ * }
155
+ * }
156
+ * ```
157
+ */
158
+ onAuthFailure?: AuthFailureHandler;
109
159
  /**
110
160
  * Optional dynamic token TTL resolver.
111
161
  *
@@ -156,6 +206,7 @@ export function jwtAuthPlugin({
156
206
  checkPermissions,
157
207
  useDynamicRoles = false,
158
208
  resolveTokenTTL,
209
+ onAuthFailure,
159
210
  }: JwtAuthPluginOptions): JwtAuthPlugin {
160
211
  return {
161
212
  authenticateRequest: async (req, permissions, ctx) =>
@@ -166,6 +217,7 @@ export function jwtAuthPlugin({
166
217
  tokenExtractor,
167
218
  checkPermissions,
168
219
  useDynamicRoles,
220
+ onAuthFailure,
169
221
  }),
170
222
  createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL, resolveTokenTTL }, roles),
171
223
  createPasswordHashAndSalt: (password: string) => createPasswordHashAndSalt(password, passwordPolicy),
@@ -178,11 +230,25 @@ async function authenticateRequest(
178
230
  routePermissions: string | string[],
179
231
  rolePermissions: { [x: string]: string[] },
180
232
  ctx: any,
181
- { secret, algo, getUser, tokenExtractor, checkPermissions, useDynamicRoles }: Pick<
233
+ { secret, algo, getUser, tokenExtractor, checkPermissions, useDynamicRoles, onAuthFailure }: Pick<
182
234
  JwtAuthPluginOptions,
183
- "algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions" | "useDynamicRoles"
235
+ "algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions" | "useDynamicRoles" | "onAuthFailure"
184
236
  >
185
- ) {
237
+ ): Promise<boolean | FlinkResponse> {
238
+ /**
239
+ * Resolves a rejected authentication: lets `onAuthFailure` shape the response,
240
+ * falls back to `false` (default 401) when no handler or no response is returned.
241
+ */
242
+ const fail = async (reason: AuthFailureReason, error?: unknown): Promise<boolean | FlinkResponse> => {
243
+ if (onAuthFailure) {
244
+ const failureRes = await onAuthFailure({ reason, error }, req, ctx);
245
+ if (failureRes) {
246
+ return failureRes;
247
+ }
248
+ }
249
+ return false;
250
+ };
251
+
186
252
  let token: string | null | undefined;
187
253
 
188
254
  if (tokenExtractor) {
@@ -199,17 +265,27 @@ async function authenticateRequest(
199
265
  token = getTokenFromReq(req);
200
266
  }
201
267
 
202
- if (token) {
268
+ if (!token) {
269
+ return fail("missing_token");
270
+ }
271
+
272
+ {
203
273
  let decodedToken;
274
+ let decodeError: unknown;
204
275
 
205
276
  try {
206
277
  decodedToken = jwtSimple.decode(token, secret, false, algo);
207
278
  } catch (err) {
208
279
  log.debug(`[JWT AUTH PLUGIN] Failed to decode token: ${err}`);
280
+ decodeError = err;
209
281
  decodedToken = null;
210
282
  }
211
283
 
212
- if (decodedToken) {
284
+ if (!decodedToken) {
285
+ return fail("invalid_token", decodeError);
286
+ }
287
+
288
+ {
213
289
  const permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
214
290
 
215
291
  // Static permission check - only if custom checker NOT provided AND not using dynamic roles
@@ -217,7 +293,7 @@ async function authenticateRequest(
217
293
  const validPerms = hasValidPermissions(decodedToken.roles || [], rolePermissions, permissionsArr);
218
294
 
219
295
  if (!validPerms) {
220
- return false;
296
+ return fail("permission_denied");
221
297
  }
222
298
  }
223
299
 
@@ -225,7 +301,7 @@ async function authenticateRequest(
225
301
 
226
302
  if (!user) {
227
303
  log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
228
- return false;
304
+ return fail("no_user");
229
305
  }
230
306
 
231
307
  // Dynamic roles: check permissions using roles from user object
@@ -234,7 +310,7 @@ async function authenticateRequest(
234
310
 
235
311
  if (!validPerms) {
236
312
  log.debug("[JWT AUTH PLUGIN] Dynamic role permission check failed");
237
- return false;
313
+ return fail("permission_denied");
238
314
  }
239
315
  }
240
316
 
@@ -244,7 +320,7 @@ async function authenticateRequest(
244
320
 
245
321
  if (!hasPermission) {
246
322
  log.debug("[JWT AUTH PLUGIN] Custom permission check failed");
247
- return false;
323
+ return fail("permission_denied");
248
324
  }
249
325
  }
250
326
 
@@ -265,7 +341,6 @@ async function authenticateRequest(
265
341
  return true;
266
342
  }
267
343
  }
268
- return false;
269
344
  }
270
345
 
271
346
  function getTokenFromReq(req: FlinkRequest) {