@flink-app/jwt-auth-plugin 0.12.1-alpha.4 → 0.12.1-alpha.41

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/readme.md CHANGED
@@ -1,33 +1,983 @@
1
- # Flink API Docs
1
+ # JWT Authentication Plugin
2
2
 
3
- **WORK IN PROGRESS 👷‍♀️👷🏻‍♂️**
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
- A FLINK plugin that generates a VERY simple documentation based on the apps
6
- registered routes and schemas.
5
+ ## Features
7
6
 
8
- ## Usage
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
13
+ - Custom token extraction (query params, cookies, custom headers)
14
+ - Dynamic permission checking (database-backed permissions)
9
15
 
10
- Install plugin to your flink app project:
16
+ ## Installation
17
+
18
+ Install the plugin in your Flink app project:
19
+
20
+ ```bash
21
+ npm install @flink-app/jwt-auth-plugin
22
+ ```
23
+
24
+ ## Basic Setup
25
+
26
+ ```typescript
27
+ import { FlinkApp } from "@flink-app/flink";
28
+ import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
29
+ import { Ctx } from "./Ctx";
30
+
31
+ function start() {
32
+ const app = new FlinkApp<Ctx>({
33
+ name: "My Flink App",
34
+ auth: jwtAuthPlugin({
35
+ secret: process.env.JWT_SECRET || "your-secret-key",
36
+ getUser: async (tokenData) => {
37
+ // Retrieve user from database using token data
38
+ const user = await ctx.repos.userRepo.findById(tokenData.userId);
39
+ return {
40
+ id: user._id,
41
+ username: user.username,
42
+ roles: user.roles,
43
+ };
44
+ },
45
+ rolePermissions: {
46
+ admin: ["read", "write", "delete", "manage_users"],
47
+ user: ["read", "write"],
48
+ guest: ["read"],
49
+ },
50
+ }),
51
+ db: {
52
+ uri: "mongodb://localhost:27017/my-app",
53
+ },
54
+ });
55
+
56
+ app.start();
57
+ }
58
+
59
+ start();
60
+ ```
61
+
62
+ ## Configuration Options
63
+
64
+ ### `JwtAuthPluginOptions`
65
+
66
+ | Option | Type | Required | Default | Description |
67
+ |--------|------|----------|---------|-------------|
68
+ | `secret` | `string` | Yes | - | Secret key used to sign and verify JWT tokens. Keep this secure! |
69
+ | `getUser` | `(tokenData: any) => Promise<FlinkAuthUser>` | Yes | - | Async function that retrieves user data from token payload |
70
+ | `rolePermissions` | `{ [role: string]: string[] }` | Yes | - | Maps roles to their allowed permissions |
71
+ | `algo` | `jwtSimple.TAlgorithm` | No | `"HS256"` | JWT signing algorithm |
72
+ | `passwordPolicy` | `RegExp` | No | `/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/` | Regex to validate password strength |
73
+ | `tokenTTL` | `number` | No | `1000 * 60 * 60 * 24 * 365 * 100` (100 years) | Token time-to-live in milliseconds |
74
+ | `tokenExtractor` | `(req: FlinkRequest) => string \| null \| undefined` | No | - | Custom token extraction function. Return `string` for token, `null` for no token, `undefined` to fallback to Bearer |
75
+ | `checkPermissions` | `(user: FlinkAuthUser, routePermissions: string[]) => Promise<boolean> \| boolean` | No | - | Custom permission validator for dynamic permissions from database. Replaces static `rolePermissions` when provided |
76
+
77
+ ### Default Password Policy
78
+
79
+ The default password policy requires:
80
+ - Minimum 8 characters
81
+ - At least one letter (A-Z or a-z)
82
+ - At least one number (0-9)
83
+
84
+ You can customize this by providing your own regex:
85
+
86
+ ```typescript
87
+ jwtAuthPlugin({
88
+ secret: "your-secret",
89
+ getUser: async (tokenData) => { /* ... */ },
90
+ rolePermissions: { /* ... */ },
91
+ passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{12,}$/,
92
+ // Requires: 12+ chars, 1 letter, 1 number, 1 special character
93
+ })
94
+ ```
95
+
96
+ ## Custom Token Extraction
97
+
98
+ By default, the plugin extracts JWT tokens from the `Authorization` header as Bearer tokens:
99
+
100
+ ```
101
+ Authorization: Bearer <token>
102
+ ```
103
+
104
+ However, you can customize token extraction using the `tokenExtractor` option. This is useful for:
105
+ - Mobile apps that pass tokens in query parameters
106
+ - Cookie-based authentication for web routes
107
+ - Custom header schemes for specific endpoints
108
+ - Different auth methods for different route patterns
109
+
110
+ ### Token Extractor Return Values
111
+
112
+ The `tokenExtractor` callback supports three return values:
113
+
114
+ - **`string`**: Token found, use this token for authentication
115
+ - **`null`**: No token found, authentication should fail (no fallback to default Bearer)
116
+ - **`undefined`**: Skip custom extraction, use default Bearer token extraction
117
+
118
+ ### Example: Query Parameter for Public API Routes
119
+
120
+ ```typescript
121
+ jwtAuthPlugin({
122
+ secret: process.env.JWT_SECRET!,
123
+ getUser: async (tokenData) => { /* ... */ },
124
+ rolePermissions: { /* ... */ },
125
+ tokenExtractor: (req) => {
126
+ // Allow query param tokens only for public API routes
127
+ if (req.path?.startsWith('/api/public/') && req.method === 'GET') {
128
+ return req.query?.token as string || null;
129
+ }
130
+ // All other routes use default Bearer token
131
+ return undefined;
132
+ }
133
+ })
134
+ ```
135
+
136
+ **Usage:**
137
+ ```
138
+ GET /api/public/data?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
139
+ ```
140
+
141
+ ### Example: Cookie-Based Auth for Web, Bearer for API
142
+
143
+ ```typescript
144
+ jwtAuthPlugin({
145
+ secret: process.env.JWT_SECRET!,
146
+ getUser: async (tokenData) => { /* ... */ },
147
+ rolePermissions: { /* ... */ },
148
+ tokenExtractor: (req) => {
149
+ // Web routes use session cookie
150
+ if (req.path?.startsWith('/web/')) {
151
+ return req.cookies?.session_token || null;
152
+ }
153
+ // API routes use Bearer token (default)
154
+ return undefined;
155
+ }
156
+ })
157
+ ```
158
+
159
+ ### Example: Custom Header for Webhooks
160
+
161
+ ```typescript
162
+ jwtAuthPlugin({
163
+ secret: process.env.JWT_SECRET!,
164
+ getUser: async (tokenData) => { /* ... */ },
165
+ rolePermissions: { /* ... */ },
166
+ tokenExtractor: (req) => {
167
+ // Webhook endpoints use custom header
168
+ if (req.path?.startsWith('/webhooks/')) {
169
+ return req.headers['x-webhook-signature'] as string || null;
170
+ }
171
+ // Other routes use Bearer token
172
+ return undefined;
173
+ }
174
+ })
175
+ ```
176
+
177
+ ### Example: Method-Based Extraction
178
+
179
+ ```typescript
180
+ jwtAuthPlugin({
181
+ secret: process.env.JWT_SECRET!,
182
+ getUser: async (tokenData) => { /* ... */ },
183
+ rolePermissions: { /* ... */ },
184
+ tokenExtractor: (req) => {
185
+ // Special handling for PATCH requests
186
+ if (req.method === 'PATCH') {
187
+ return req.headers['x-patch-token'] as string || null;
188
+ }
189
+ // All other methods use Bearer
190
+ return undefined;
191
+ }
192
+ })
193
+ ```
194
+
195
+ ### Example: Multiple Fallbacks
196
+
197
+ ```typescript
198
+ jwtAuthPlugin({
199
+ secret: process.env.JWT_SECRET!,
200
+ getUser: async (tokenData) => { /* ... */ },
201
+ rolePermissions: { /* ... */ },
202
+ tokenExtractor: (req) => {
203
+ // Try cookie first for browser requests
204
+ if (req.headers['user-agent']?.includes('Mozilla')) {
205
+ const cookieToken = req.cookies?.auth_token;
206
+ if (cookieToken) return cookieToken;
207
+ }
208
+
209
+ // Try query param for mobile apps
210
+ if (req.query?.token) {
211
+ return req.query.token as string;
212
+ }
213
+
214
+ // Fall back to default Bearer token extraction
215
+ return undefined;
216
+ }
217
+ })
218
+ ```
219
+
220
+ ### Important Notes
221
+
222
+ - When `tokenExtractor` returns `undefined`, the plugin falls back to extracting from `Authorization: Bearer <token>`
223
+ - When it returns `null`, authentication fails immediately (useful to enforce specific auth methods for certain routes)
224
+ - When it returns a `string`, that token is validated using the same JWT verification logic
225
+ - The callback has access to `req.path`, `req.method`, `req.headers`, `req.query`, `req.cookies`, etc.
226
+
227
+ ## Dynamic Permissions with Database
228
+
229
+ By default, the plugin uses static `rolePermissions` defined at configuration time. However, you can implement dynamic permissions that are fetched from the database on each request using the `checkPermissions` callback.
230
+
231
+ ### When to Use Dynamic Permissions
232
+
233
+ Use `checkPermissions` when:
234
+ - Permissions are stored in the database per user or per role
235
+ - Permissions can change without restarting the application
236
+ - Different organizations/tenants have different permission sets
237
+ - You need fine-grained, user-specific permissions
238
+
239
+ ### How It Works
240
+
241
+ When you provide `checkPermissions`:
242
+ 1. Token is extracted and decoded (same as before)
243
+ 2. Static `rolePermissions` check is **skipped**
244
+ 3. `getUser` is called - this is where you fetch permissions from DB
245
+ 4. `checkPermissions` is called with the user object and required route permissions
246
+ 5. If `checkPermissions` returns `true`, authentication succeeds
247
+
248
+ ### Basic Example
249
+
250
+ ```typescript
251
+ jwtAuthPlugin({
252
+ secret: process.env.JWT_SECRET!,
253
+
254
+ getUser: async (tokenData) => {
255
+ // Fetch user from database
256
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
257
+
258
+ // Fetch user's permissions from database
259
+ const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
260
+
261
+ return {
262
+ id: user._id,
263
+ username: user.username,
264
+ roles: user.roles,
265
+ permissions, // Attach permissions to user object
266
+ };
267
+ },
268
+
269
+ // rolePermissions can be empty when using dynamic permissions
270
+ rolePermissions: {},
271
+
272
+ // Custom permission checker
273
+ checkPermissions: async (user, routePermissions) => {
274
+ // User must have ALL required permissions
275
+ return routePermissions.every(perm =>
276
+ user.permissions?.includes(perm)
277
+ );
278
+ },
279
+ })
280
+ ```
281
+
282
+ ### Multi-Tenant Example
283
+
284
+ ```typescript
285
+ jwtAuthPlugin({
286
+ secret: process.env.JWT_SECRET!,
287
+
288
+ getUser: async (tokenData) => {
289
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
290
+
291
+ // Fetch permissions based on user's organization
292
+ const permissions = await ctx.repos.permissionRepo.getOrgPermissions(
293
+ user._id,
294
+ user.organizationId
295
+ );
296
+
297
+ return {
298
+ id: user._id,
299
+ username: user.username,
300
+ organizationId: user.organizationId,
301
+ permissions,
302
+ };
303
+ },
304
+
305
+ rolePermissions: {},
306
+
307
+ checkPermissions: async (user, routePermissions) => {
308
+ return routePermissions.every(perm =>
309
+ user.permissions?.includes(perm)
310
+ );
311
+ },
312
+ })
313
+ ```
314
+
315
+ ### Hybrid: Role-Based + User-Specific Permissions
316
+
317
+ ```typescript
318
+ jwtAuthPlugin({
319
+ secret: process.env.JWT_SECRET!,
320
+
321
+ getUser: async (tokenData) => {
322
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
323
+
324
+ // Get base permissions from roles
325
+ const rolePerms = await ctx.repos.roleRepo.getRolePermissions(user.roles);
326
+
327
+ // Get user-specific permission overrides
328
+ const userPerms = await ctx.repos.permissionRepo.getUserPermissions(user._id);
329
+
330
+ // Combine both
331
+ const allPermissions = [...new Set([...rolePerms, ...userPerms])];
332
+
333
+ return {
334
+ id: user._id,
335
+ username: user.username,
336
+ roles: user.roles,
337
+ permissions: allPermissions,
338
+ };
339
+ },
340
+
341
+ rolePermissions: {},
342
+
343
+ checkPermissions: async (user, routePermissions) => {
344
+ return routePermissions.every(perm =>
345
+ user.permissions?.includes(perm)
346
+ );
347
+ },
348
+ })
349
+ ```
350
+
351
+ ### Permission with Wildcards
352
+
353
+ ```typescript
354
+ checkPermissions: async (user, routePermissions) => {
355
+ // Support wildcard permissions
356
+ if (user.permissions?.includes("*")) {
357
+ return true; // User has all permissions
358
+ }
359
+
360
+ // Check specific permissions
361
+ return routePermissions.every(perm =>
362
+ user.permissions?.includes(perm)
363
+ );
364
+ }
365
+ ```
366
+
367
+ ### Permission with OR Logic
368
+
369
+ ```typescript
370
+ checkPermissions: async (user, routePermissions) => {
371
+ // User needs ANY of the route permissions (OR logic)
372
+ return routePermissions.some(perm =>
373
+ user.permissions?.includes(perm)
374
+ );
375
+ }
376
+ ```
377
+
378
+ ### Caching Permissions for Performance
379
+
380
+ To reduce database load, you can cache permissions:
381
+
382
+ ```typescript
383
+ const permissionCache = new Map();
384
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
385
+
386
+ jwtAuthPlugin({
387
+ secret: process.env.JWT_SECRET!,
388
+
389
+ getUser: async (tokenData) => {
390
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
391
+
392
+ // Check cache first
393
+ const cacheKey = `perms:${user._id}`;
394
+ const cached = permissionCache.get(cacheKey);
395
+
396
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
397
+ return {
398
+ id: user._id,
399
+ username: user.username,
400
+ permissions: cached.permissions,
401
+ };
402
+ }
403
+
404
+ // Fetch from DB
405
+ const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
406
+
407
+ // Cache it
408
+ permissionCache.set(cacheKey, {
409
+ permissions,
410
+ timestamp: Date.now(),
411
+ });
412
+
413
+ return {
414
+ id: user._id,
415
+ username: user.username,
416
+ permissions,
417
+ };
418
+ },
419
+
420
+ rolePermissions: {},
421
+ checkPermissions: async (user, routePermissions) => {
422
+ return routePermissions.every(perm => user.permissions?.includes(perm));
423
+ },
424
+ })
425
+ ```
426
+
427
+ ### Dynamic Permissions Notes
428
+
429
+ - **Backward Compatible**: If you don't provide `checkPermissions`, static `rolePermissions` are used (existing behavior)
430
+ - **Performance**: `checkPermissions` is called on every authenticated request, so ensure `getUser` is optimized (consider caching)
431
+ - **User Object**: The `user` parameter in `checkPermissions` is the exact object returned from `getUser`
432
+ - **Public Routes**: If a route has no permissions (`[]`), `checkPermissions` is NOT called
433
+ - **Sync or Async**: `checkPermissions` can return `Promise<boolean>` or `boolean`
434
+
435
+ ### Troubleshooting Dynamic Permissions
436
+
437
+ **Issue**: Too many database queries
438
+
439
+ **Solution**: Implement permission caching in `getUser` or use an in-memory cache like Redis
440
+
441
+ **Issue**: Permissions not updating after database change
442
+
443
+ **Solution**: Clear permission cache or reduce cache TTL
444
+
445
+ ## Context API
446
+
447
+ Once configured, the plugin provides the following methods via the `auth` context:
448
+
449
+ ### `createToken(payload: any, roles: string[]): Promise<string>`
450
+
451
+ Creates a JWT token with the provided payload and roles.
452
+
453
+ ```typescript
454
+ const token = await ctx.auth.createToken(
455
+ { userId: user._id, username: user.username },
456
+ ["user"]
457
+ );
458
+ ```
459
+
460
+ **Parameters:**
461
+ - `payload`: Any data to encode in the token (typically user ID and username)
462
+ - `roles`: Array of role names assigned to the user
463
+
464
+ **Returns:** JWT token string
465
+
466
+ **Example:**
467
+ ```typescript
468
+ // In a login handler
469
+ const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
470
+ const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
471
+
472
+ if (!user) {
473
+ return { status: 401, error: { code: "invalid_credentials" } };
474
+ }
475
+
476
+ const token = await ctx.auth.createToken(
477
+ { userId: user._id, username: user.username },
478
+ user.roles
479
+ );
480
+
481
+ return {
482
+ data: {
483
+ token,
484
+ user: {
485
+ id: user._id,
486
+ username: user.username,
487
+ },
488
+ },
489
+ };
490
+ };
491
+ ```
492
+
493
+ ### `createPasswordHashAndSalt(password: string): Promise<{ hash: string; salt: string } | null>`
494
+
495
+ Generates a secure password hash and salt using bcrypt.
496
+
497
+ ```typescript
498
+ const result = await ctx.auth.createPasswordHashAndSalt("mypassword123");
499
+ if (result) {
500
+ const { hash, salt } = result;
501
+ // Save hash and salt to database
502
+ }
503
+ ```
504
+
505
+ **Parameters:**
506
+ - `password`: The plain text password to hash
507
+
508
+ **Returns:**
509
+ - Object with `hash` and `salt` if password meets policy
510
+ - `null` if password doesn't meet the configured password policy
511
+
512
+ **Security Note:** Both hash and salt must be stored in your database to validate passwords later.
513
+
514
+ **Example:**
515
+ ```typescript
516
+ // Creating a new user
517
+ const handler: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
518
+ const passwordData = await ctx.auth.createPasswordHashAndSalt(req.body.password);
519
+
520
+ if (!passwordData) {
521
+ return {
522
+ status: 400,
523
+ error: {
524
+ code: "weak_password",
525
+ message: "Password does not meet security requirements",
526
+ },
527
+ };
528
+ }
529
+
530
+ const user = await ctx.repos.userRepo.create({
531
+ username: req.body.username,
532
+ password: passwordData.hash,
533
+ salt: passwordData.salt,
534
+ roles: ["user"],
535
+ });
536
+
537
+ return { data: { userId: user._id } };
538
+ };
539
+ ```
540
+
541
+ ### `validatePassword(password: string, passwordHash: string, salt: string): Promise<boolean>`
542
+
543
+ Validates a password against a stored hash and salt.
544
+
545
+ ```typescript
546
+ const isValid = await ctx.auth.validatePassword(
547
+ "mypassword123",
548
+ user.password,
549
+ user.salt
550
+ );
551
+ ```
552
+
553
+ **Parameters:**
554
+ - `password`: Plain text password to validate
555
+ - `passwordHash`: Stored password hash from database
556
+ - `salt`: Stored salt from database
557
+
558
+ **Returns:** `true` if password matches, `false` otherwise
559
+
560
+ **Example:**
561
+ ```typescript
562
+ // In a login handler
563
+ const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
564
+ const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
565
+
566
+ if (!user) {
567
+ return { status: 401, error: { code: "invalid_credentials" } };
568
+ }
569
+
570
+ const isValidPassword = await ctx.auth.validatePassword(
571
+ req.body.password,
572
+ user.password,
573
+ user.salt
574
+ );
575
+
576
+ if (!isValidPassword) {
577
+ return { status: 401, error: { code: "invalid_credentials" } };
578
+ }
579
+
580
+ const token = await ctx.auth.createToken(
581
+ { userId: user._id, username: user.username },
582
+ user.roles
583
+ );
584
+
585
+ return {
586
+ data: {
587
+ token,
588
+ user: { id: user._id, username: user.username },
589
+ },
590
+ };
591
+ };
592
+ ```
593
+
594
+ ### `authenticateRequest(req: FlinkRequest, permissions: string | string[]): Promise<boolean>`
595
+
596
+ Automatically called by Flink framework to authenticate requests. You typically don't call this directly.
597
+
598
+ ## Role-Based Access Control
599
+
600
+ ### Defining Roles and Permissions
601
+
602
+ ```typescript
603
+ jwtAuthPlugin({
604
+ secret: "your-secret",
605
+ getUser: async (tokenData) => { /* ... */ },
606
+ rolePermissions: {
607
+ // Admin role can do everything
608
+ admin: ["read", "write", "delete", "manage_users", "view_analytics"],
609
+
610
+ // Regular user has limited permissions
611
+ user: ["read", "write"],
612
+
613
+ // Guest can only read
614
+ guest: ["read"],
615
+ },
616
+ })
617
+ ```
618
+
619
+ ### Protecting Routes with Permissions
620
+
621
+ Use the `permission` property in your route configuration to restrict access:
622
+
623
+ ```typescript
624
+ // Only authenticated users (any role)
625
+ export const Route: RouteProps = {
626
+ path: "/api/profile",
627
+ permission: "read", // Must have "read" permission
628
+ };
629
+
630
+ // Only admins
631
+ export const Route: RouteProps = {
632
+ path: "/api/admin/users",
633
+ permission: "manage_users", // Must have "manage_users" permission
634
+ };
635
+
636
+ // Multiple permissions (user must have at least one)
637
+ export const Route: RouteProps = {
638
+ path: "/api/content",
639
+ permission: ["read", "write"], // Must have either "read" OR "write"
640
+ };
641
+ ```
642
+
643
+ ### Accessing User in Handlers
644
+
645
+ Once authenticated, the user object is available in `req.user`:
646
+
647
+ ```typescript
648
+ const handler: Handler<Ctx, any, any> = async ({ ctx, req }) => {
649
+ // Access authenticated user
650
+ const userId = req.user?.id;
651
+ const username = req.user?.username;
652
+ const roles = req.user?.roles;
653
+
654
+ // Use user data in your logic
655
+ const data = await ctx.repos.dataRepo.findByUserId(userId);
656
+
657
+ return { data };
658
+ };
659
+ ```
660
+
661
+ ## Making Authenticated Requests
662
+
663
+ Clients must include the JWT token in the `Authorization` header:
11
664
 
12
665
  ```
13
- npm i -S @flink-app/api-docs-plugin
666
+ Authorization: Bearer <your-jwt-token>
14
667
  ```
15
668
 
16
- Add and configure plugin in your app startup (probable the `index.ts` in root project):
669
+ ### Example with fetch
17
670
 
671
+ ```javascript
672
+ const response = await fetch('https://api.example.com/profile', {
673
+ method: 'GET',
674
+ headers: {
675
+ 'Authorization': `Bearer ${token}`,
676
+ 'Content-Type': 'application/json',
677
+ },
678
+ });
18
679
  ```
19
- import { apiDocPlugin } from "@flink-app/api-docs-plugin";
680
+
681
+ ### Example with axios
682
+
683
+ ```javascript
684
+ const response = await axios.get('https://api.example.com/profile', {
685
+ headers: {
686
+ 'Authorization': `Bearer ${token}`,
687
+ },
688
+ });
689
+ ```
690
+
691
+ ## Complete Example
692
+
693
+ ```typescript
694
+ // index.ts
695
+ import { FlinkApp } from "@flink-app/flink";
696
+ import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
697
+ import { Ctx } from "./Ctx";
20
698
 
21
699
  function start() {
22
- new FlinkApp<AppContext>({
23
- name: "My app",
24
- plugins: [
25
- // Register plugin, customize options if needed to
26
- apiDocPlugin({
27
- title: "API Docs: My app"
28
- })
29
- ],
30
- }).start();
700
+ const app = new FlinkApp<Ctx>({
701
+ name: "My App",
702
+ auth: jwtAuthPlugin({
703
+ secret: process.env.JWT_SECRET!,
704
+ getUser: async (tokenData) => {
705
+ const user = await app.ctx.repos.userRepo.findById(tokenData.userId);
706
+ return {
707
+ id: user._id,
708
+ username: user.username,
709
+ roles: user.roles,
710
+ };
711
+ },
712
+ rolePermissions: {
713
+ admin: ["read", "write", "delete", "manage_users"],
714
+ user: ["read", "write"],
715
+ },
716
+ passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{10,}$/,
717
+ tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
718
+ }),
719
+ db: {
720
+ uri: process.env.MONGODB_URI!,
721
+ },
722
+ });
723
+
724
+ app.start();
725
+ }
726
+
727
+ start();
728
+
729
+ // handlers/auth/PostLogin.ts
730
+ import { Handler, RouteProps } from "@flink-app/flink";
731
+ import { Ctx } from "../../Ctx";
732
+ import LoginReq from "../../schemas/LoginReq";
733
+ import LoginRes from "../../schemas/LoginRes";
734
+
735
+ export const Route: RouteProps = {
736
+ path: "/auth/login",
737
+ };
738
+
739
+ const PostLogin: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
740
+ const { username, password } = req.body;
741
+
742
+ // Find user
743
+ const user = await ctx.repos.userRepo.findOne({ username });
744
+ if (!user) {
745
+ return {
746
+ status: 401,
747
+ error: { code: "invalid_credentials", message: "Invalid username or password" },
748
+ };
749
+ }
750
+
751
+ // Validate password
752
+ const isValid = await ctx.auth.validatePassword(password, user.password, user.salt);
753
+ if (!isValid) {
754
+ return {
755
+ status: 401,
756
+ error: { code: "invalid_credentials", message: "Invalid username or password" },
757
+ };
758
+ }
759
+
760
+ // Create token
761
+ const token = await ctx.auth.createToken(
762
+ { userId: user._id, username: user.username },
763
+ user.roles
764
+ );
765
+
766
+ return {
767
+ data: {
768
+ token,
769
+ user: {
770
+ id: user._id,
771
+ username: user.username,
772
+ roles: user.roles,
773
+ },
774
+ },
775
+ };
776
+ };
777
+
778
+ export default PostLogin;
779
+
780
+ // handlers/users/PostUser.ts
781
+ import { Handler, RouteProps } from "@flink-app/flink";
782
+ import { Ctx } from "../../Ctx";
783
+ import CreateUserReq from "../../schemas/CreateUserReq";
784
+ import CreateUserRes from "../../schemas/CreateUserRes";
785
+
786
+ export const Route: RouteProps = {
787
+ path: "/users",
788
+ permission: "manage_users", // Only admins can create users
789
+ };
790
+
791
+ const PostUser: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
792
+ const { username, password, roles } = req.body;
793
+
794
+ // Check if user exists
795
+ const existingUser = await ctx.repos.userRepo.findOne({ username });
796
+ if (existingUser) {
797
+ return {
798
+ status: 409,
799
+ error: { code: "user_exists", message: "Username already taken" },
800
+ };
801
+ }
802
+
803
+ // Hash password
804
+ const passwordData = await ctx.auth.createPasswordHashAndSalt(password);
805
+ if (!passwordData) {
806
+ return {
807
+ status: 400,
808
+ error: {
809
+ code: "weak_password",
810
+ message: "Password does not meet security requirements",
811
+ },
812
+ };
813
+ }
814
+
815
+ // Create user
816
+ const user = await ctx.repos.userRepo.create({
817
+ username,
818
+ password: passwordData.hash,
819
+ salt: passwordData.salt,
820
+ roles: roles || ["user"],
821
+ createdAt: new Date(),
822
+ });
823
+
824
+ return {
825
+ data: {
826
+ id: user._id,
827
+ username: user.username,
828
+ roles: user.roles,
829
+ },
830
+ };
831
+ };
832
+
833
+ export default PostUser;
834
+ ```
835
+
836
+ ## Security Best Practices
837
+
838
+ ### 1. Secret Key Management
839
+
840
+ Never hardcode your JWT secret. Use environment variables:
841
+
842
+ ```typescript
843
+ jwtAuthPlugin({
844
+ secret: process.env.JWT_SECRET!,
845
+ // ...
846
+ })
847
+ ```
848
+
849
+ Generate a strong secret:
850
+ ```bash
851
+ node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
852
+ ```
853
+
854
+ ### 2. Token Expiration
855
+
856
+ Set an appropriate TTL for your use case:
857
+
858
+ ```typescript
859
+ jwtAuthPlugin({
860
+ secret: process.env.JWT_SECRET!,
861
+ tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
862
+ // ...
863
+ })
864
+ ```
865
+
866
+ ### 3. Password Policies
867
+
868
+ Enforce strong password requirements:
869
+
870
+ ```typescript
871
+ jwtAuthPlugin({
872
+ secret: process.env.JWT_SECRET!,
873
+ // Require: 12+ chars, uppercase, lowercase, number, special char
874
+ passwordPolicy: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,}$/,
875
+ // ...
876
+ })
877
+ ```
878
+
879
+ ### 4. HTTPS Only
880
+
881
+ Always use HTTPS in production to prevent token interception.
882
+
883
+ ### 5. Token Storage (Client-Side)
884
+
885
+ - Avoid `localStorage` (vulnerable to XSS)
886
+ - Prefer `httpOnly` cookies or secure session storage
887
+ - Implement token refresh mechanisms for long-lived sessions
888
+
889
+ ### 6. Rate Limiting
890
+
891
+ Implement rate limiting on authentication endpoints to prevent brute force attacks.
892
+
893
+ ## TypeScript Types
894
+
895
+ ```typescript
896
+ import { JwtAuthPlugin, JwtAuthPluginOptions } from "@flink-app/jwt-auth-plugin";
897
+
898
+ // Token extractor callback type
899
+ type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
900
+
901
+ // Permission checker callback type
902
+ type PermissionChecker = (
903
+ user: FlinkAuthUser,
904
+ routePermissions: string[]
905
+ ) => Promise<boolean> | boolean;
906
+
907
+ // Plugin options
908
+ interface JwtAuthPluginOptions {
909
+ secret: string;
910
+ algo?: jwtSimple.TAlgorithm;
911
+ getUser: (tokenData: any) => Promise<FlinkAuthUser>;
912
+ passwordPolicy?: RegExp;
913
+ tokenTTL?: number;
914
+ rolePermissions: {
915
+ [role: string]: string[];
916
+ };
917
+ tokenExtractor?: TokenExtractor;
918
+ checkPermissions?: PermissionChecker;
919
+ }
920
+
921
+ // Plugin interface
922
+ interface JwtAuthPlugin extends FlinkAuthPlugin {
923
+ createToken: (payload: any, roles: string[]) => Promise<string>;
924
+ createPasswordHashAndSalt: (
925
+ password: string
926
+ ) => Promise<{ hash: string; salt: string } | null>;
927
+ validatePassword: (
928
+ password: string,
929
+ passwordHash: string,
930
+ salt: string
931
+ ) => Promise<boolean>;
932
+ }
933
+
934
+ // Authenticated user (from Flink framework)
935
+ interface FlinkAuthUser {
936
+ id: string;
937
+ username?: string;
938
+ roles?: string[];
939
+ [key: string]: any;
31
940
  }
941
+ ```
942
+
943
+ ## Troubleshooting
944
+
945
+ ### Token Validation Fails
946
+
947
+ **Issue:** Requests return 401 Unauthorized
948
+
949
+ **Solutions:**
950
+ - Verify the token is being sent in the `Authorization` header
951
+ - Check the header format: `Authorization: Bearer <token>`
952
+ - Ensure the secret used to sign matches the secret used to verify
953
+ - Check if the token has expired (if TTL is configured)
954
+
955
+ ### Password Creation Returns Null
956
+
957
+ **Issue:** `createPasswordHashAndSalt` returns `null`
32
958
 
959
+ **Solution:** Password doesn't meet the configured `passwordPolicy`. Update the password or adjust the policy.
960
+
961
+ ### getUser Function Errors
962
+
963
+ **Issue:** Authentication fails with error from `getUser`
964
+
965
+ **Solution:** Ensure your `getUser` function properly handles missing users:
966
+
967
+ ```typescript
968
+ getUser: async (tokenData) => {
969
+ const user = await ctx.repos.userRepo.findById(tokenData.userId);
970
+ if (!user) {
971
+ throw new Error("User not found");
972
+ }
973
+ return {
974
+ id: user._id,
975
+ username: user.username,
976
+ roles: user.roles,
977
+ };
978
+ }
33
979
  ```
980
+
981
+ ## License
982
+
983
+ MIT