@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 +17 -0
- package/dist/FlinkJwtAuthPlugin.d.ts +45 -2
- package/dist/FlinkJwtAuthPlugin.js +34 -10
- package/package.json +3 -3
- package/spec/FlinkJwtAuthPlugin.spec.ts +121 -0
- package/src/FlinkJwtAuthPlugin.ts +86 -11
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
|
|
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)
|
|
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)
|
|
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*/,
|
|
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*/,
|
|
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*/,
|
|
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*/,
|
|
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.
|
|
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.
|
|
22
|
+
"@flink-app/flink": "2.0.0-alpha.106"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@flink-app/flink": ">=2.0.0-alpha.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|