@flink-app/jwt-auth-plugin 0.12.1-alpha.9 → 0.13.0
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 +7 -0
- package/dist/BcryptUtils.js +2 -3
- package/dist/FlinkJwtAuthPlugin.d.ts +68 -3
- package/dist/FlinkJwtAuthPlugin.js +55 -23
- package/dist/PermissionValidator.js +1 -2
- package/package.json +31 -34
- package/readme.md +1301 -18
- package/spec/FlinkJwtAuthPlugin.spec.ts +692 -5
- package/src/FlinkJwtAuthPlugin.ts +213 -130
- package/tsconfig.json +1 -1
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
FlinkAuthPlugin,
|
|
3
|
-
FlinkAuthUser,
|
|
4
|
-
FlinkRequest,
|
|
5
|
-
log,
|
|
6
|
-
} from "@flink-app/flink";
|
|
1
|
+
import { FlinkAuthPlugin, FlinkAuthUser, FlinkRequest, log } from "@flink-app/flink";
|
|
7
2
|
import jwtSimple from "jwt-simple";
|
|
8
3
|
import { encrypt, genSalt } from "./BcryptUtils";
|
|
9
4
|
import { hasValidPermissions } from "./PermissionValidator";
|
|
@@ -14,167 +9,255 @@ import { hasValidPermissions } from "./PermissionValidator";
|
|
|
14
9
|
*/
|
|
15
10
|
const defaultPasswordPolicy = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
|
|
16
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
|
+
|
|
17
37
|
export interface JwtAuthPluginOptions {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
secret: string;
|
|
39
|
+
algo?: jwtSimple.TAlgorithm;
|
|
40
|
+
getUser: (tokenData: any, req: FlinkRequest) => Promise<FlinkAuthUser | null | undefined>;
|
|
41
|
+
passwordPolicy?: RegExp;
|
|
42
|
+
tokenTTL?: number;
|
|
43
|
+
rolePermissions: {
|
|
44
|
+
[role: string]: string[];
|
|
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;
|
|
69
|
+
/**
|
|
70
|
+
* When true, uses roles from the user object returned by getUser
|
|
71
|
+
* instead of roles from the decoded token for static permission checking.
|
|
72
|
+
*
|
|
73
|
+
* Useful for multi-tenant scenarios where user roles vary by organization context.
|
|
74
|
+
* The organization context can be determined from request headers, subdomain, path, etc.
|
|
75
|
+
*
|
|
76
|
+
* Example:
|
|
77
|
+
* ```typescript
|
|
78
|
+
* useDynamicRoles: true,
|
|
79
|
+
* getUser: async (tokenData, req) => {
|
|
80
|
+
* const orgId = req.headers['x-organization-id'];
|
|
81
|
+
* const membership = await getOrgMembership(tokenData.userId, orgId);
|
|
82
|
+
* return {
|
|
83
|
+
* id: tokenData.userId,
|
|
84
|
+
* roles: [membership.role], // Org-specific role
|
|
85
|
+
* };
|
|
86
|
+
* }
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
useDynamicRoles?: boolean;
|
|
26
90
|
}
|
|
27
91
|
|
|
28
92
|
export interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*/
|
|
52
|
-
validatePassword: (
|
|
53
|
-
password: string,
|
|
54
|
-
passwordHash: string,
|
|
55
|
-
salt: string
|
|
56
|
-
) => Promise<boolean>;
|
|
93
|
+
/**
|
|
94
|
+
* Encodes and returns JWT token that includes provided payload.
|
|
95
|
+
*
|
|
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.
|
|
98
|
+
*/
|
|
99
|
+
createToken: (payload: any, roles: string[]) => Promise<string>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generates new password hash and salt for provided password.
|
|
103
|
+
*
|
|
104
|
+
* This method should be used when setting a new password. Both hash and salt needs
|
|
105
|
+
* to be saved in database as both are needed to validate the password.
|
|
106
|
+
*
|
|
107
|
+
* Returns null if password does not match configured `passwordPolicy`.
|
|
108
|
+
*/
|
|
109
|
+
createPasswordHashAndSalt: (password: string) => Promise<{ hash: string; salt: string } | null>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validates that provided `password` is same as provided `hash`.
|
|
113
|
+
*/
|
|
114
|
+
validatePassword: (password: string, passwordHash: string, salt: string) => Promise<boolean>;
|
|
57
115
|
}
|
|
58
116
|
|
|
59
117
|
/**
|
|
60
118
|
* Configures and creates authentication plugin.
|
|
61
119
|
*/
|
|
62
120
|
export function jwtAuthPlugin({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
121
|
+
secret,
|
|
122
|
+
getUser,
|
|
123
|
+
rolePermissions,
|
|
124
|
+
algo = "HS256",
|
|
125
|
+
passwordPolicy = defaultPasswordPolicy,
|
|
126
|
+
tokenTTL = 1000 * 60 * 60 * 24 * 365 * 100, //Defaults to hundred year
|
|
127
|
+
tokenExtractor,
|
|
128
|
+
checkPermissions,
|
|
129
|
+
useDynamicRoles = false,
|
|
69
130
|
}: JwtAuthPluginOptions): JwtAuthPlugin {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
131
|
+
return {
|
|
132
|
+
authenticateRequest: async (req, permissions) =>
|
|
133
|
+
authenticateRequest(req, permissions, rolePermissions, {
|
|
134
|
+
algo,
|
|
135
|
+
secret,
|
|
136
|
+
getUser,
|
|
137
|
+
tokenExtractor,
|
|
138
|
+
checkPermissions,
|
|
139
|
+
useDynamicRoles,
|
|
140
|
+
}),
|
|
141
|
+
createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL }),
|
|
142
|
+
createPasswordHashAndSalt: (password: string) => createPasswordHashAndSalt(password, passwordPolicy),
|
|
143
|
+
validatePassword,
|
|
144
|
+
};
|
|
83
145
|
}
|
|
84
146
|
|
|
85
147
|
async function authenticateRequest(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}: Pick<JwtAuthPluginOptions, "algo" | "secret" | "getUser">
|
|
148
|
+
req: FlinkRequest,
|
|
149
|
+
routePermissions: string | string[],
|
|
150
|
+
rolePermissions: { [x: string]: string[] },
|
|
151
|
+
{ secret, algo, getUser, tokenExtractor, checkPermissions, useDynamicRoles }: Pick<
|
|
152
|
+
JwtAuthPluginOptions,
|
|
153
|
+
"algo" | "secret" | "getUser" | "tokenExtractor" | "checkPermissions" | "useDynamicRoles"
|
|
154
|
+
>
|
|
94
155
|
) {
|
|
95
|
-
|
|
156
|
+
let token: string | null | undefined;
|
|
96
157
|
|
|
97
|
-
|
|
98
|
-
|
|
158
|
+
if (tokenExtractor) {
|
|
159
|
+
token = tokenExtractor(req);
|
|
99
160
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
161
|
+
// If tokenExtractor returns undefined, fall back to default
|
|
162
|
+
if (token === undefined) {
|
|
163
|
+
token = getTokenFromReq(req);
|
|
164
|
+
}
|
|
165
|
+
// If it returns null, token stays null (no default fallback)
|
|
166
|
+
// If it returns string, token is the string
|
|
167
|
+
} else {
|
|
168
|
+
// No custom extractor, use default
|
|
169
|
+
token = getTokenFromReq(req);
|
|
105
170
|
}
|
|
106
171
|
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
? routePermissions
|
|
110
|
-
: [routePermissions];
|
|
111
|
-
|
|
112
|
-
if (permissionsArr && permissionsArr.length > 0) {
|
|
113
|
-
const validPerms = hasValidPermissions(
|
|
114
|
-
decodedToken.roles || [],
|
|
115
|
-
rolePermissions,
|
|
116
|
-
permissionsArr
|
|
117
|
-
);
|
|
172
|
+
if (token) {
|
|
173
|
+
let decodedToken;
|
|
118
174
|
|
|
119
|
-
|
|
120
|
-
|
|
175
|
+
try {
|
|
176
|
+
decodedToken = jwtSimple.decode(token, secret, false, algo);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
log.debug(`[JWT AUTH PLUGIN] Failed to decode token: ${err}`);
|
|
179
|
+
decodedToken = null;
|
|
121
180
|
}
|
|
122
|
-
}
|
|
123
181
|
|
|
124
|
-
|
|
182
|
+
if (decodedToken) {
|
|
183
|
+
const permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
|
|
125
184
|
|
|
185
|
+
// Static permission check - only if custom checker NOT provided AND not using dynamic roles
|
|
186
|
+
if (!checkPermissions && !useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
|
|
187
|
+
const validPerms = hasValidPermissions(decodedToken.roles || [], rolePermissions, permissionsArr);
|
|
126
188
|
|
|
127
|
-
|
|
128
|
-
|
|
189
|
+
if (!validPerms) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const user = await getUser(decodedToken, req);
|
|
195
|
+
|
|
196
|
+
if (!user) {
|
|
197
|
+
log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Dynamic roles: check permissions using roles from user object
|
|
202
|
+
if (!checkPermissions && useDynamicRoles && permissionsArr && permissionsArr.length > 0) {
|
|
203
|
+
const validPerms = hasValidPermissions(user.roles || [], rolePermissions, permissionsArr);
|
|
204
|
+
|
|
205
|
+
if (!validPerms) {
|
|
206
|
+
log.debug("[JWT AUTH PLUGIN] Dynamic role permission check failed");
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Custom permission check - only if provided
|
|
212
|
+
if (checkPermissions && permissionsArr && permissionsArr.length > 0) {
|
|
213
|
+
const hasPermission = await checkPermissions(user, permissionsArr);
|
|
214
|
+
|
|
215
|
+
if (!hasPermission) {
|
|
216
|
+
log.debug("[JWT AUTH PLUGIN] Custom permission check failed");
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
req.user = user;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
129
224
|
}
|
|
130
|
-
|
|
131
|
-
return false;
|
|
225
|
+
return false;
|
|
132
226
|
}
|
|
133
227
|
|
|
134
228
|
function getTokenFromReq(req: FlinkRequest) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
229
|
+
const authHeader = req.headers.authorization;
|
|
230
|
+
if (authHeader) {
|
|
231
|
+
const [, token] = authHeader.split("Bearer ");
|
|
232
|
+
return token;
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
141
235
|
}
|
|
142
236
|
|
|
143
|
-
async function createToken(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!payload) {
|
|
148
|
-
throw new Error("Cannot create token - payload is missing");
|
|
149
|
-
}
|
|
237
|
+
async function createToken(payload: any, { secret, algo, tokenTTL }: Pick<JwtAuthPluginOptions, "algo" | "secret" | "tokenTTL">) {
|
|
238
|
+
if (!payload) {
|
|
239
|
+
throw new Error("Cannot create token - payload is missing");
|
|
240
|
+
}
|
|
150
241
|
|
|
151
|
-
|
|
242
|
+
return jwtSimple.encode({ exp: _calculateExpiration(tokenTTL || 1000 * 60 * 60 * 24 * 365 * 100), ...payload }, secret, algo);
|
|
152
243
|
}
|
|
153
244
|
|
|
154
|
-
function _calculateExpiration(expiresInMs
|
|
245
|
+
function _calculateExpiration(expiresInMs: number) {
|
|
155
246
|
return Math.floor((Date.now() + expiresInMs) / 1000);
|
|
156
247
|
}
|
|
157
248
|
|
|
249
|
+
async function createPasswordHashAndSalt(password: string, passwordPolicy: RegExp) {
|
|
250
|
+
if (!passwordPolicy.test(password)) {
|
|
251
|
+
log.debug(`Password does not match password policy '${passwordPolicy}'`);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
158
254
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
) {
|
|
163
|
-
if (!passwordPolicy.test(password)) {
|
|
164
|
-
log.debug(`Password does not match password policy '${passwordPolicy}'`);
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const salt = await genSalt(10);
|
|
169
|
-
const hash = await encrypt(password, salt);
|
|
170
|
-
return { salt, hash };
|
|
255
|
+
const salt = await genSalt(10);
|
|
256
|
+
const hash = await encrypt(password, salt);
|
|
257
|
+
return { salt, hash };
|
|
171
258
|
}
|
|
172
259
|
|
|
173
|
-
async function validatePassword(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
salt: string
|
|
177
|
-
) {
|
|
178
|
-
const hashCandidate = await encrypt(password, salt);
|
|
179
|
-
return hashCandidate === passwordHash;
|
|
260
|
+
async function validatePassword(password: string, passwordHash: string, salt: string) {
|
|
261
|
+
const hashCandidate = await encrypt(password, salt);
|
|
262
|
+
return hashCandidate === passwordHash;
|
|
180
263
|
}
|