@flink-app/jwt-auth-plugin 0.12.1-alpha.3 → 0.12.1-alpha.34
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/dist/FlinkJwtAuthPlugin.d.ts +1 -1
- package/dist/FlinkJwtAuthPlugin.js +8 -10
- package/package.json +4 -4
- package/readme.md +604 -18
- package/src/FlinkJwtAuthPlugin.ts +101 -131
|
@@ -3,7 +3,7 @@ import jwtSimple from "jwt-simple";
|
|
|
3
3
|
export interface JwtAuthPluginOptions {
|
|
4
4
|
secret: string;
|
|
5
5
|
algo?: jwtSimple.TAlgorithm;
|
|
6
|
-
getUser: (tokenData: any) => Promise<FlinkAuthUser>;
|
|
6
|
+
getUser: (tokenData: any) => Promise<FlinkAuthUser | null | undefined>;
|
|
7
7
|
passwordPolicy?: RegExp;
|
|
8
8
|
tokenTTL?: number;
|
|
9
9
|
rolePermissions: {
|
|
@@ -76,12 +76,8 @@ function jwtAuthPlugin(_a) {
|
|
|
76
76
|
})];
|
|
77
77
|
});
|
|
78
78
|
}); },
|
|
79
|
-
createToken: function (payload, roles) {
|
|
80
|
-
|
|
81
|
-
},
|
|
82
|
-
createPasswordHashAndSalt: function (password) {
|
|
83
|
-
return createPasswordHashAndSalt(password, passwordPolicy);
|
|
84
|
-
},
|
|
79
|
+
createToken: function (payload, roles) { return createToken(__assign(__assign({}, payload), { roles: roles }), { algo: algo, secret: secret, tokenTTL: tokenTTL }); },
|
|
80
|
+
createPasswordHashAndSalt: function (password) { return createPasswordHashAndSalt(password, passwordPolicy); },
|
|
85
81
|
validatePassword: validatePassword,
|
|
86
82
|
};
|
|
87
83
|
}
|
|
@@ -100,13 +96,11 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
|
|
|
100
96
|
decodedToken = jwt_simple_1.default.decode(token, secret, false, algo);
|
|
101
97
|
}
|
|
102
98
|
catch (err) {
|
|
103
|
-
flink_1.log.debug("Failed to decode token: ".concat(err));
|
|
99
|
+
flink_1.log.debug("[JWT AUTH PLUGIN] Failed to decode token: ".concat(err));
|
|
104
100
|
decodedToken = null;
|
|
105
101
|
}
|
|
106
102
|
if (!decodedToken) return [3 /*break*/, 2];
|
|
107
|
-
permissionsArr = Array.isArray(routePermissions)
|
|
108
|
-
? routePermissions
|
|
109
|
-
: [routePermissions];
|
|
103
|
+
permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
|
|
110
104
|
if (permissionsArr && permissionsArr.length > 0) {
|
|
111
105
|
validPerms = (0, PermissionValidator_1.hasValidPermissions)(decodedToken.roles || [], rolePermissions, permissionsArr);
|
|
112
106
|
if (!validPerms) {
|
|
@@ -116,6 +110,10 @@ function authenticateRequest(req_1, routePermissions_1, rolePermissions_1, _a) {
|
|
|
116
110
|
return [4 /*yield*/, getUser(decodedToken)];
|
|
117
111
|
case 1:
|
|
118
112
|
user = _c.sent();
|
|
113
|
+
if (!user) {
|
|
114
|
+
flink_1.log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
|
|
115
|
+
return [2 /*return*/, false];
|
|
116
|
+
}
|
|
119
117
|
req.user = user;
|
|
120
118
|
return [2 /*return*/, true];
|
|
121
119
|
case 2: return [2 /*return*/, false];
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flink-app/jwt-auth-plugin",
|
|
3
|
-
"version": "0.12.1-alpha.
|
|
3
|
+
"version": "0.12.1-alpha.34",
|
|
4
4
|
"description": "Flink plugin for JWT auth",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --preserve-symlinks -r ts-node/register -- node_modules/jasmine/bin/jasmine --config=./spec/support/jasmine.json",
|
|
7
7
|
"test:watch": "nodemon --ext ts --exec 'jasmine-ts --config=./spec/support/jasmine.json'",
|
|
8
|
-
"
|
|
8
|
+
"prepare": "tsc --project tsconfig.dist.json",
|
|
9
9
|
"watch": "tsc-watch --project tsconfig.dist.json"
|
|
10
10
|
},
|
|
11
11
|
"author": "joel@frost.se",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"jwt-simple": "^0.5.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@flink-app/flink": "^0.12.1-alpha.
|
|
23
|
+
"@flink-app/flink": "^0.12.1-alpha.23",
|
|
24
24
|
"@types/bcrypt": "^5.0.0",
|
|
25
25
|
"@types/jasmine": "^3.7.1",
|
|
26
26
|
"@types/node": "22.13.10",
|
|
@@ -31,5 +31,5 @@
|
|
|
31
31
|
"tsc-watch": "^4.2.9",
|
|
32
32
|
"typescript": "5.4.5"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "633fab738980da1433aff45adc9da8ab0755bd69"
|
|
35
35
|
}
|
package/readme.md
CHANGED
|
@@ -1,33 +1,619 @@
|
|
|
1
|
-
#
|
|
1
|
+
# JWT Authentication Plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Flink authentication plugin that provides JWT (JSON Web Token) based authentication with role-based permissions, password hashing, and token management.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
registered routes and schemas.
|
|
5
|
+
## Features
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
- JWT token generation and validation
|
|
8
|
+
- Password hashing using bcrypt
|
|
9
|
+
- Role-based access control with permissions
|
|
10
|
+
- Configurable password policies
|
|
11
|
+
- Token expiration support
|
|
12
|
+
- Bearer token authentication
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
## Installation
|
|
11
15
|
|
|
16
|
+
Install the plugin in your Flink app project:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @flink-app/jwt-auth-plugin
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Basic Setup
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
26
|
+
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
|
|
27
|
+
import { Ctx } from "./Ctx";
|
|
28
|
+
|
|
29
|
+
function start() {
|
|
30
|
+
const app = new FlinkApp<Ctx>({
|
|
31
|
+
name: "My Flink App",
|
|
32
|
+
auth: jwtAuthPlugin({
|
|
33
|
+
secret: process.env.JWT_SECRET || "your-secret-key",
|
|
34
|
+
getUser: async (tokenData) => {
|
|
35
|
+
// Retrieve user from database using token data
|
|
36
|
+
const user = await ctx.repos.userRepo.findById(tokenData.userId);
|
|
37
|
+
return {
|
|
38
|
+
id: user._id,
|
|
39
|
+
username: user.username,
|
|
40
|
+
roles: user.roles,
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
rolePermissions: {
|
|
44
|
+
admin: ["read", "write", "delete", "manage_users"],
|
|
45
|
+
user: ["read", "write"],
|
|
46
|
+
guest: ["read"],
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
db: {
|
|
50
|
+
uri: "mongodb://localhost:27017/my-app",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
app.start();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
start();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration Options
|
|
61
|
+
|
|
62
|
+
### `JwtAuthPluginOptions`
|
|
63
|
+
|
|
64
|
+
| Option | Type | Required | Default | Description |
|
|
65
|
+
|--------|------|----------|---------|-------------|
|
|
66
|
+
| `secret` | `string` | Yes | - | Secret key used to sign and verify JWT tokens. Keep this secure! |
|
|
67
|
+
| `getUser` | `(tokenData: any) => Promise<FlinkAuthUser>` | Yes | - | Async function that retrieves user data from token payload |
|
|
68
|
+
| `rolePermissions` | `{ [role: string]: string[] }` | Yes | - | Maps roles to their allowed permissions |
|
|
69
|
+
| `algo` | `jwtSimple.TAlgorithm` | No | `"HS256"` | JWT signing algorithm |
|
|
70
|
+
| `passwordPolicy` | `RegExp` | No | `/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/` | Regex to validate password strength |
|
|
71
|
+
| `tokenTTL` | `number` | No | `1000 * 60 * 60 * 24 * 365 * 100` (100 years) | Token time-to-live in milliseconds |
|
|
72
|
+
|
|
73
|
+
### Default Password Policy
|
|
74
|
+
|
|
75
|
+
The default password policy requires:
|
|
76
|
+
- Minimum 8 characters
|
|
77
|
+
- At least one letter (A-Z or a-z)
|
|
78
|
+
- At least one number (0-9)
|
|
79
|
+
|
|
80
|
+
You can customize this by providing your own regex:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
jwtAuthPlugin({
|
|
84
|
+
secret: "your-secret",
|
|
85
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
86
|
+
rolePermissions: { /* ... */ },
|
|
87
|
+
passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{12,}$/,
|
|
88
|
+
// Requires: 12+ chars, 1 letter, 1 number, 1 special character
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Context API
|
|
93
|
+
|
|
94
|
+
Once configured, the plugin provides the following methods via the `auth` context:
|
|
95
|
+
|
|
96
|
+
### `createToken(payload: any, roles: string[]): Promise<string>`
|
|
97
|
+
|
|
98
|
+
Creates a JWT token with the provided payload and roles.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const token = await ctx.auth.createToken(
|
|
102
|
+
{ userId: user._id, username: user.username },
|
|
103
|
+
["user"]
|
|
104
|
+
);
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Parameters:**
|
|
108
|
+
- `payload`: Any data to encode in the token (typically user ID and username)
|
|
109
|
+
- `roles`: Array of role names assigned to the user
|
|
110
|
+
|
|
111
|
+
**Returns:** JWT token string
|
|
112
|
+
|
|
113
|
+
**Example:**
|
|
114
|
+
```typescript
|
|
115
|
+
// In a login handler
|
|
116
|
+
const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
|
|
117
|
+
const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
|
|
118
|
+
|
|
119
|
+
if (!user) {
|
|
120
|
+
return { status: 401, error: { code: "invalid_credentials" } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const token = await ctx.auth.createToken(
|
|
124
|
+
{ userId: user._id, username: user.username },
|
|
125
|
+
user.roles
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
data: {
|
|
130
|
+
token,
|
|
131
|
+
user: {
|
|
132
|
+
id: user._id,
|
|
133
|
+
username: user.username,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### `createPasswordHashAndSalt(password: string): Promise<{ hash: string; salt: string } | null>`
|
|
141
|
+
|
|
142
|
+
Generates a secure password hash and salt using bcrypt.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
const result = await ctx.auth.createPasswordHashAndSalt("mypassword123");
|
|
146
|
+
if (result) {
|
|
147
|
+
const { hash, salt } = result;
|
|
148
|
+
// Save hash and salt to database
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Parameters:**
|
|
153
|
+
- `password`: The plain text password to hash
|
|
154
|
+
|
|
155
|
+
**Returns:**
|
|
156
|
+
- Object with `hash` and `salt` if password meets policy
|
|
157
|
+
- `null` if password doesn't meet the configured password policy
|
|
158
|
+
|
|
159
|
+
**Security Note:** Both hash and salt must be stored in your database to validate passwords later.
|
|
160
|
+
|
|
161
|
+
**Example:**
|
|
162
|
+
```typescript
|
|
163
|
+
// Creating a new user
|
|
164
|
+
const handler: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
|
|
165
|
+
const passwordData = await ctx.auth.createPasswordHashAndSalt(req.body.password);
|
|
166
|
+
|
|
167
|
+
if (!passwordData) {
|
|
168
|
+
return {
|
|
169
|
+
status: 400,
|
|
170
|
+
error: {
|
|
171
|
+
code: "weak_password",
|
|
172
|
+
message: "Password does not meet security requirements",
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const user = await ctx.repos.userRepo.create({
|
|
178
|
+
username: req.body.username,
|
|
179
|
+
password: passwordData.hash,
|
|
180
|
+
salt: passwordData.salt,
|
|
181
|
+
roles: ["user"],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return { data: { userId: user._id } };
|
|
185
|
+
};
|
|
12
186
|
```
|
|
13
|
-
|
|
187
|
+
|
|
188
|
+
### `validatePassword(password: string, passwordHash: string, salt: string): Promise<boolean>`
|
|
189
|
+
|
|
190
|
+
Validates a password against a stored hash and salt.
|
|
191
|
+
|
|
192
|
+
```typescript
|
|
193
|
+
const isValid = await ctx.auth.validatePassword(
|
|
194
|
+
"mypassword123",
|
|
195
|
+
user.password,
|
|
196
|
+
user.salt
|
|
197
|
+
);
|
|
14
198
|
```
|
|
15
199
|
|
|
16
|
-
|
|
200
|
+
**Parameters:**
|
|
201
|
+
- `password`: Plain text password to validate
|
|
202
|
+
- `passwordHash`: Stored password hash from database
|
|
203
|
+
- `salt`: Stored salt from database
|
|
204
|
+
|
|
205
|
+
**Returns:** `true` if password matches, `false` otherwise
|
|
17
206
|
|
|
207
|
+
**Example:**
|
|
208
|
+
```typescript
|
|
209
|
+
// In a login handler
|
|
210
|
+
const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
|
|
211
|
+
const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
|
|
212
|
+
|
|
213
|
+
if (!user) {
|
|
214
|
+
return { status: 401, error: { code: "invalid_credentials" } };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const isValidPassword = await ctx.auth.validatePassword(
|
|
218
|
+
req.body.password,
|
|
219
|
+
user.password,
|
|
220
|
+
user.salt
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
if (!isValidPassword) {
|
|
224
|
+
return { status: 401, error: { code: "invalid_credentials" } };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const token = await ctx.auth.createToken(
|
|
228
|
+
{ userId: user._id, username: user.username },
|
|
229
|
+
user.roles
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
data: {
|
|
234
|
+
token,
|
|
235
|
+
user: { id: user._id, username: user.username },
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
};
|
|
18
239
|
```
|
|
19
|
-
|
|
240
|
+
|
|
241
|
+
### `authenticateRequest(req: FlinkRequest, permissions: string | string[]): Promise<boolean>`
|
|
242
|
+
|
|
243
|
+
Automatically called by Flink framework to authenticate requests. You typically don't call this directly.
|
|
244
|
+
|
|
245
|
+
## Role-Based Access Control
|
|
246
|
+
|
|
247
|
+
### Defining Roles and Permissions
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
jwtAuthPlugin({
|
|
251
|
+
secret: "your-secret",
|
|
252
|
+
getUser: async (tokenData) => { /* ... */ },
|
|
253
|
+
rolePermissions: {
|
|
254
|
+
// Admin role can do everything
|
|
255
|
+
admin: ["read", "write", "delete", "manage_users", "view_analytics"],
|
|
256
|
+
|
|
257
|
+
// Regular user has limited permissions
|
|
258
|
+
user: ["read", "write"],
|
|
259
|
+
|
|
260
|
+
// Guest can only read
|
|
261
|
+
guest: ["read"],
|
|
262
|
+
},
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Protecting Routes with Permissions
|
|
267
|
+
|
|
268
|
+
Use the `permission` property in your route configuration to restrict access:
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
// Only authenticated users (any role)
|
|
272
|
+
export const Route: RouteProps = {
|
|
273
|
+
path: "/api/profile",
|
|
274
|
+
permission: "read", // Must have "read" permission
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Only admins
|
|
278
|
+
export const Route: RouteProps = {
|
|
279
|
+
path: "/api/admin/users",
|
|
280
|
+
permission: "manage_users", // Must have "manage_users" permission
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// Multiple permissions (user must have at least one)
|
|
284
|
+
export const Route: RouteProps = {
|
|
285
|
+
path: "/api/content",
|
|
286
|
+
permission: ["read", "write"], // Must have either "read" OR "write"
|
|
287
|
+
};
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Accessing User in Handlers
|
|
291
|
+
|
|
292
|
+
Once authenticated, the user object is available in `req.user`:
|
|
293
|
+
|
|
294
|
+
```typescript
|
|
295
|
+
const handler: Handler<Ctx, any, any> = async ({ ctx, req }) => {
|
|
296
|
+
// Access authenticated user
|
|
297
|
+
const userId = req.user?.id;
|
|
298
|
+
const username = req.user?.username;
|
|
299
|
+
const roles = req.user?.roles;
|
|
300
|
+
|
|
301
|
+
// Use user data in your logic
|
|
302
|
+
const data = await ctx.repos.dataRepo.findByUserId(userId);
|
|
303
|
+
|
|
304
|
+
return { data };
|
|
305
|
+
};
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Making Authenticated Requests
|
|
309
|
+
|
|
310
|
+
Clients must include the JWT token in the `Authorization` header:
|
|
311
|
+
|
|
312
|
+
```
|
|
313
|
+
Authorization: Bearer <your-jwt-token>
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Example with fetch
|
|
317
|
+
|
|
318
|
+
```javascript
|
|
319
|
+
const response = await fetch('https://api.example.com/profile', {
|
|
320
|
+
method: 'GET',
|
|
321
|
+
headers: {
|
|
322
|
+
'Authorization': `Bearer ${token}`,
|
|
323
|
+
'Content-Type': 'application/json',
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Example with axios
|
|
329
|
+
|
|
330
|
+
```javascript
|
|
331
|
+
const response = await axios.get('https://api.example.com/profile', {
|
|
332
|
+
headers: {
|
|
333
|
+
'Authorization': `Bearer ${token}`,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
## Complete Example
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
// index.ts
|
|
342
|
+
import { FlinkApp } from "@flink-app/flink";
|
|
343
|
+
import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
|
|
344
|
+
import { Ctx } from "./Ctx";
|
|
20
345
|
|
|
21
346
|
function start() {
|
|
22
|
-
new FlinkApp<
|
|
23
|
-
name: "My
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
347
|
+
const app = new FlinkApp<Ctx>({
|
|
348
|
+
name: "My App",
|
|
349
|
+
auth: jwtAuthPlugin({
|
|
350
|
+
secret: process.env.JWT_SECRET!,
|
|
351
|
+
getUser: async (tokenData) => {
|
|
352
|
+
const user = await app.ctx.repos.userRepo.findById(tokenData.userId);
|
|
353
|
+
return {
|
|
354
|
+
id: user._id,
|
|
355
|
+
username: user.username,
|
|
356
|
+
roles: user.roles,
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
rolePermissions: {
|
|
360
|
+
admin: ["read", "write", "delete", "manage_users"],
|
|
361
|
+
user: ["read", "write"],
|
|
362
|
+
},
|
|
363
|
+
passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{10,}$/,
|
|
364
|
+
tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
|
|
365
|
+
}),
|
|
366
|
+
db: {
|
|
367
|
+
uri: process.env.MONGODB_URI!,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
app.start();
|
|
31
372
|
}
|
|
32
373
|
|
|
374
|
+
start();
|
|
375
|
+
|
|
376
|
+
// handlers/auth/PostLogin.ts
|
|
377
|
+
import { Handler, RouteProps } from "@flink-app/flink";
|
|
378
|
+
import { Ctx } from "../../Ctx";
|
|
379
|
+
import LoginReq from "../../schemas/LoginReq";
|
|
380
|
+
import LoginRes from "../../schemas/LoginRes";
|
|
381
|
+
|
|
382
|
+
export const Route: RouteProps = {
|
|
383
|
+
path: "/auth/login",
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const PostLogin: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
|
|
387
|
+
const { username, password } = req.body;
|
|
388
|
+
|
|
389
|
+
// Find user
|
|
390
|
+
const user = await ctx.repos.userRepo.findOne({ username });
|
|
391
|
+
if (!user) {
|
|
392
|
+
return {
|
|
393
|
+
status: 401,
|
|
394
|
+
error: { code: "invalid_credentials", message: "Invalid username or password" },
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Validate password
|
|
399
|
+
const isValid = await ctx.auth.validatePassword(password, user.password, user.salt);
|
|
400
|
+
if (!isValid) {
|
|
401
|
+
return {
|
|
402
|
+
status: 401,
|
|
403
|
+
error: { code: "invalid_credentials", message: "Invalid username or password" },
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Create token
|
|
408
|
+
const token = await ctx.auth.createToken(
|
|
409
|
+
{ userId: user._id, username: user.username },
|
|
410
|
+
user.roles
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
data: {
|
|
415
|
+
token,
|
|
416
|
+
user: {
|
|
417
|
+
id: user._id,
|
|
418
|
+
username: user.username,
|
|
419
|
+
roles: user.roles,
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
export default PostLogin;
|
|
426
|
+
|
|
427
|
+
// handlers/users/PostUser.ts
|
|
428
|
+
import { Handler, RouteProps } from "@flink-app/flink";
|
|
429
|
+
import { Ctx } from "../../Ctx";
|
|
430
|
+
import CreateUserReq from "../../schemas/CreateUserReq";
|
|
431
|
+
import CreateUserRes from "../../schemas/CreateUserRes";
|
|
432
|
+
|
|
433
|
+
export const Route: RouteProps = {
|
|
434
|
+
path: "/users",
|
|
435
|
+
permission: "manage_users", // Only admins can create users
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const PostUser: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
|
|
439
|
+
const { username, password, roles } = req.body;
|
|
440
|
+
|
|
441
|
+
// Check if user exists
|
|
442
|
+
const existingUser = await ctx.repos.userRepo.findOne({ username });
|
|
443
|
+
if (existingUser) {
|
|
444
|
+
return {
|
|
445
|
+
status: 409,
|
|
446
|
+
error: { code: "user_exists", message: "Username already taken" },
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Hash password
|
|
451
|
+
const passwordData = await ctx.auth.createPasswordHashAndSalt(password);
|
|
452
|
+
if (!passwordData) {
|
|
453
|
+
return {
|
|
454
|
+
status: 400,
|
|
455
|
+
error: {
|
|
456
|
+
code: "weak_password",
|
|
457
|
+
message: "Password does not meet security requirements",
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Create user
|
|
463
|
+
const user = await ctx.repos.userRepo.create({
|
|
464
|
+
username,
|
|
465
|
+
password: passwordData.hash,
|
|
466
|
+
salt: passwordData.salt,
|
|
467
|
+
roles: roles || ["user"],
|
|
468
|
+
createdAt: new Date(),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
data: {
|
|
473
|
+
id: user._id,
|
|
474
|
+
username: user.username,
|
|
475
|
+
roles: user.roles,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
export default PostUser;
|
|
33
481
|
```
|
|
482
|
+
|
|
483
|
+
## Security Best Practices
|
|
484
|
+
|
|
485
|
+
### 1. Secret Key Management
|
|
486
|
+
|
|
487
|
+
Never hardcode your JWT secret. Use environment variables:
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
jwtAuthPlugin({
|
|
491
|
+
secret: process.env.JWT_SECRET!,
|
|
492
|
+
// ...
|
|
493
|
+
})
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Generate a strong secret:
|
|
497
|
+
```bash
|
|
498
|
+
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
### 2. Token Expiration
|
|
502
|
+
|
|
503
|
+
Set an appropriate TTL for your use case:
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
jwtAuthPlugin({
|
|
507
|
+
secret: process.env.JWT_SECRET!,
|
|
508
|
+
tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
|
|
509
|
+
// ...
|
|
510
|
+
})
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
### 3. Password Policies
|
|
514
|
+
|
|
515
|
+
Enforce strong password requirements:
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
jwtAuthPlugin({
|
|
519
|
+
secret: process.env.JWT_SECRET!,
|
|
520
|
+
// Require: 12+ chars, uppercase, lowercase, number, special char
|
|
521
|
+
passwordPolicy: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,}$/,
|
|
522
|
+
// ...
|
|
523
|
+
})
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### 4. HTTPS Only
|
|
527
|
+
|
|
528
|
+
Always use HTTPS in production to prevent token interception.
|
|
529
|
+
|
|
530
|
+
### 5. Token Storage (Client-Side)
|
|
531
|
+
|
|
532
|
+
- Avoid `localStorage` (vulnerable to XSS)
|
|
533
|
+
- Prefer `httpOnly` cookies or secure session storage
|
|
534
|
+
- Implement token refresh mechanisms for long-lived sessions
|
|
535
|
+
|
|
536
|
+
### 6. Rate Limiting
|
|
537
|
+
|
|
538
|
+
Implement rate limiting on authentication endpoints to prevent brute force attacks.
|
|
539
|
+
|
|
540
|
+
## TypeScript Types
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
import { JwtAuthPlugin, JwtAuthPluginOptions } from "@flink-app/jwt-auth-plugin";
|
|
544
|
+
|
|
545
|
+
// Plugin options
|
|
546
|
+
interface JwtAuthPluginOptions {
|
|
547
|
+
secret: string;
|
|
548
|
+
algo?: jwtSimple.TAlgorithm;
|
|
549
|
+
getUser: (tokenData: any) => Promise<FlinkAuthUser>;
|
|
550
|
+
passwordPolicy?: RegExp;
|
|
551
|
+
tokenTTL?: number;
|
|
552
|
+
rolePermissions: {
|
|
553
|
+
[role: string]: string[];
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Plugin interface
|
|
558
|
+
interface JwtAuthPlugin extends FlinkAuthPlugin {
|
|
559
|
+
createToken: (payload: any, roles: string[]) => Promise<string>;
|
|
560
|
+
createPasswordHashAndSalt: (
|
|
561
|
+
password: string
|
|
562
|
+
) => Promise<{ hash: string; salt: string } | null>;
|
|
563
|
+
validatePassword: (
|
|
564
|
+
password: string,
|
|
565
|
+
passwordHash: string,
|
|
566
|
+
salt: string
|
|
567
|
+
) => Promise<boolean>;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Authenticated user (from Flink framework)
|
|
571
|
+
interface FlinkAuthUser {
|
|
572
|
+
id: string;
|
|
573
|
+
username?: string;
|
|
574
|
+
roles?: string[];
|
|
575
|
+
[key: string]: any;
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Troubleshooting
|
|
580
|
+
|
|
581
|
+
### Token Validation Fails
|
|
582
|
+
|
|
583
|
+
**Issue:** Requests return 401 Unauthorized
|
|
584
|
+
|
|
585
|
+
**Solutions:**
|
|
586
|
+
- Verify the token is being sent in the `Authorization` header
|
|
587
|
+
- Check the header format: `Authorization: Bearer <token>`
|
|
588
|
+
- Ensure the secret used to sign matches the secret used to verify
|
|
589
|
+
- Check if the token has expired (if TTL is configured)
|
|
590
|
+
|
|
591
|
+
### Password Creation Returns Null
|
|
592
|
+
|
|
593
|
+
**Issue:** `createPasswordHashAndSalt` returns `null`
|
|
594
|
+
|
|
595
|
+
**Solution:** Password doesn't meet the configured `passwordPolicy`. Update the password or adjust the policy.
|
|
596
|
+
|
|
597
|
+
### getUser Function Errors
|
|
598
|
+
|
|
599
|
+
**Issue:** Authentication fails with error from `getUser`
|
|
600
|
+
|
|
601
|
+
**Solution:** Ensure your `getUser` function properly handles missing users:
|
|
602
|
+
|
|
603
|
+
```typescript
|
|
604
|
+
getUser: async (tokenData) => {
|
|
605
|
+
const user = await ctx.repos.userRepo.findById(tokenData.userId);
|
|
606
|
+
if (!user) {
|
|
607
|
+
throw new Error("User not found");
|
|
608
|
+
}
|
|
609
|
+
return {
|
|
610
|
+
id: user._id,
|
|
611
|
+
username: user.username,
|
|
612
|
+
roles: user.roles,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
## License
|
|
618
|
+
|
|
619
|
+
MIT
|
|
@@ -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";
|
|
@@ -15,166 +10,141 @@ import { hasValidPermissions } from "./PermissionValidator";
|
|
|
15
10
|
const defaultPasswordPolicy = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/;
|
|
16
11
|
|
|
17
12
|
export interface JwtAuthPluginOptions {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
secret: string;
|
|
14
|
+
algo?: jwtSimple.TAlgorithm;
|
|
15
|
+
getUser: (tokenData: any) => Promise<FlinkAuthUser | null | undefined>;
|
|
16
|
+
passwordPolicy?: RegExp;
|
|
17
|
+
tokenTTL?: number;
|
|
18
|
+
rolePermissions: {
|
|
19
|
+
[role: string]: string[];
|
|
20
|
+
};
|
|
26
21
|
}
|
|
27
22
|
|
|
28
23
|
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>;
|
|
24
|
+
/**
|
|
25
|
+
* Encodes and returns JWT token that includes provided payload.
|
|
26
|
+
*
|
|
27
|
+
* The payload can by anything but should in most cases be and object that
|
|
28
|
+
* holds user information including an identifier such as the username or id.
|
|
29
|
+
*/
|
|
30
|
+
createToken: (payload: any, roles: string[]) => Promise<string>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generates new password hash and salt for provided password.
|
|
34
|
+
*
|
|
35
|
+
* This method should be used when setting a new password. Both hash and salt needs
|
|
36
|
+
* to be saved in database as both are needed to validate the password.
|
|
37
|
+
*
|
|
38
|
+
* Returns null if password does not match configured `passwordPolicy`.
|
|
39
|
+
*/
|
|
40
|
+
createPasswordHashAndSalt: (password: string) => Promise<{ hash: string; salt: string } | null>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validates that provided `password` is same as provided `hash`.
|
|
44
|
+
*/
|
|
45
|
+
validatePassword: (password: string, passwordHash: string, salt: string) => Promise<boolean>;
|
|
57
46
|
}
|
|
58
47
|
|
|
59
48
|
/**
|
|
60
49
|
* Configures and creates authentication plugin.
|
|
61
50
|
*/
|
|
62
51
|
export function jwtAuthPlugin({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
52
|
+
secret,
|
|
53
|
+
getUser,
|
|
54
|
+
rolePermissions,
|
|
55
|
+
algo = "HS256",
|
|
56
|
+
passwordPolicy = defaultPasswordPolicy,
|
|
57
|
+
tokenTTL = 1000 * 60 * 60 * 24 * 365 * 100, //Defaults to hundred year
|
|
69
58
|
}: JwtAuthPluginOptions): JwtAuthPlugin {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
validatePassword,
|
|
82
|
-
};
|
|
59
|
+
return {
|
|
60
|
+
authenticateRequest: async (req, permissions) =>
|
|
61
|
+
authenticateRequest(req, permissions, rolePermissions, {
|
|
62
|
+
algo,
|
|
63
|
+
secret,
|
|
64
|
+
getUser,
|
|
65
|
+
}),
|
|
66
|
+
createToken: (payload, roles) => createToken({ ...payload, roles }, { algo, secret, tokenTTL }),
|
|
67
|
+
createPasswordHashAndSalt: (password: string) => createPasswordHashAndSalt(password, passwordPolicy),
|
|
68
|
+
validatePassword,
|
|
69
|
+
};
|
|
83
70
|
}
|
|
84
71
|
|
|
85
72
|
async function authenticateRequest(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
secret,
|
|
91
|
-
algo,
|
|
92
|
-
getUser,
|
|
93
|
-
}: Pick<JwtAuthPluginOptions, "algo" | "secret" | "getUser">
|
|
73
|
+
req: FlinkRequest,
|
|
74
|
+
routePermissions: string | string[],
|
|
75
|
+
rolePermissions: { [x: string]: string[] },
|
|
76
|
+
{ secret, algo, getUser }: Pick<JwtAuthPluginOptions, "algo" | "secret" | "getUser">
|
|
94
77
|
) {
|
|
95
|
-
|
|
78
|
+
const token = getTokenFromReq(req);
|
|
96
79
|
|
|
97
|
-
|
|
98
|
-
|
|
80
|
+
if (token) {
|
|
81
|
+
let decodedToken;
|
|
99
82
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
83
|
+
try {
|
|
84
|
+
decodedToken = jwtSimple.decode(token, secret, false, algo);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
log.debug(`[JWT AUTH PLUGIN] Failed to decode token: ${err}`);
|
|
87
|
+
decodedToken = null;
|
|
88
|
+
}
|
|
106
89
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
? routePermissions
|
|
110
|
-
: [routePermissions];
|
|
90
|
+
if (decodedToken) {
|
|
91
|
+
const permissionsArr = Array.isArray(routePermissions) ? routePermissions : [routePermissions];
|
|
111
92
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
decodedToken.roles || [],
|
|
115
|
-
rolePermissions,
|
|
116
|
-
permissionsArr
|
|
117
|
-
);
|
|
93
|
+
if (permissionsArr && permissionsArr.length > 0) {
|
|
94
|
+
const validPerms = hasValidPermissions(decodedToken.roles || [], rolePermissions, permissionsArr);
|
|
118
95
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
96
|
+
if (!validPerms) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
123
100
|
|
|
124
|
-
|
|
101
|
+
const user = await getUser(decodedToken);
|
|
125
102
|
|
|
103
|
+
if (!user) {
|
|
104
|
+
log.debug("[JWT AUTH PLUGIN] User not returned from getUser callback");
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
126
107
|
|
|
127
|
-
|
|
128
|
-
|
|
108
|
+
req.user = user;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
129
111
|
}
|
|
130
|
-
|
|
131
|
-
return false;
|
|
112
|
+
return false;
|
|
132
113
|
}
|
|
133
114
|
|
|
134
115
|
function getTokenFromReq(req: FlinkRequest) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
116
|
+
const authHeader = req.headers.authorization;
|
|
117
|
+
if (authHeader) {
|
|
118
|
+
const [, token] = authHeader.split("Bearer ");
|
|
119
|
+
return token;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
141
122
|
}
|
|
142
123
|
|
|
143
|
-
async function createToken(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (!payload) {
|
|
148
|
-
throw new Error("Cannot create token - payload is missing");
|
|
149
|
-
}
|
|
124
|
+
async function createToken(payload: any, { secret, algo, tokenTTL }: Pick<JwtAuthPluginOptions, "algo" | "secret" | "tokenTTL">) {
|
|
125
|
+
if (!payload) {
|
|
126
|
+
throw new Error("Cannot create token - payload is missing");
|
|
127
|
+
}
|
|
150
128
|
|
|
151
|
-
|
|
129
|
+
return jwtSimple.encode({ exp: _calculateExpiration(tokenTTL || 1000 * 60 * 60 * 24 * 365 * 100), ...payload }, secret, algo);
|
|
152
130
|
}
|
|
153
131
|
|
|
154
|
-
function _calculateExpiration(expiresInMs
|
|
132
|
+
function _calculateExpiration(expiresInMs: number) {
|
|
155
133
|
return Math.floor((Date.now() + expiresInMs) / 1000);
|
|
156
134
|
}
|
|
157
135
|
|
|
136
|
+
async function createPasswordHashAndSalt(password: string, passwordPolicy: RegExp) {
|
|
137
|
+
if (!passwordPolicy.test(password)) {
|
|
138
|
+
log.debug(`Password does not match password policy '${passwordPolicy}'`);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
158
141
|
|
|
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 };
|
|
142
|
+
const salt = await genSalt(10);
|
|
143
|
+
const hash = await encrypt(password, salt);
|
|
144
|
+
return { salt, hash };
|
|
171
145
|
}
|
|
172
146
|
|
|
173
|
-
async function validatePassword(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
salt: string
|
|
177
|
-
) {
|
|
178
|
-
const hashCandidate = await encrypt(password, salt);
|
|
179
|
-
return hashCandidate === passwordHash;
|
|
147
|
+
async function validatePassword(password: string, passwordHash: string, salt: string) {
|
|
148
|
+
const hashCandidate = await encrypt(password, salt);
|
|
149
|
+
return hashCandidate === passwordHash;
|
|
180
150
|
}
|