@flink-app/jwt-auth-plugin 0.12.1-alpha.9 → 0.13.1

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,1316 @@
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
11
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, req: FlinkRequest) => Promise<FlinkAuthUser>` | Yes | - | Async function that retrieves user data from token payload. Has access to the full request object for context (headers, path, etc.) |
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
+ | `useDynamicRoles` | `boolean` | No | `false` | When `true`, uses roles from the user object returned by `getUser` instead of roles from the token. Useful for multi-tenant scenarios where user roles vary by organization |
77
+
78
+ ### Default Password Policy
79
+
80
+ The default password policy requires:
81
+ - Minimum 8 characters
82
+ - At least one letter (A-Z or a-z)
83
+ - At least one number (0-9)
84
+
85
+ You can customize this by providing your own regex:
86
+
87
+ ```typescript
88
+ jwtAuthPlugin({
89
+ secret: "your-secret",
90
+ getUser: async (tokenData) => { /* ... */ },
91
+ rolePermissions: { /* ... */ },
92
+ passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{12,}$/,
93
+ // Requires: 12+ chars, 1 letter, 1 number, 1 special character
94
+ })
95
+ ```
96
+
97
+ ## Custom Token Extraction
98
+
99
+ By default, the plugin extracts JWT tokens from the `Authorization` header as Bearer tokens:
100
+
101
+ ```
102
+ Authorization: Bearer <token>
103
+ ```
104
+
105
+ However, you can customize token extraction using the `tokenExtractor` option. This is useful for:
106
+ - Mobile apps that pass tokens in query parameters
107
+ - Cookie-based authentication for web routes
108
+ - Custom header schemes for specific endpoints
109
+ - Different auth methods for different route patterns
110
+
111
+ ### Token Extractor Return Values
112
+
113
+ The `tokenExtractor` callback supports three return values:
114
+
115
+ - **`string`**: Token found, use this token for authentication
116
+ - **`null`**: No token found, authentication should fail (no fallback to default Bearer)
117
+ - **`undefined`**: Skip custom extraction, use default Bearer token extraction
118
+
119
+ ### Example: Query Parameter for Public API Routes
120
+
121
+ ```typescript
122
+ jwtAuthPlugin({
123
+ secret: process.env.JWT_SECRET!,
124
+ getUser: async (tokenData) => { /* ... */ },
125
+ rolePermissions: { /* ... */ },
126
+ tokenExtractor: (req) => {
127
+ // Allow query param tokens only for public API routes
128
+ if (req.path?.startsWith('/api/public/') && req.method === 'GET') {
129
+ return req.query?.token as string || null;
130
+ }
131
+ // All other routes use default Bearer token
132
+ return undefined;
133
+ }
134
+ })
135
+ ```
136
+
137
+ **Usage:**
138
+ ```
139
+ GET /api/public/data?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
140
+ ```
141
+
142
+ ### Example: Cookie-Based Auth for Web, Bearer for API
143
+
144
+ ```typescript
145
+ jwtAuthPlugin({
146
+ secret: process.env.JWT_SECRET!,
147
+ getUser: async (tokenData) => { /* ... */ },
148
+ rolePermissions: { /* ... */ },
149
+ tokenExtractor: (req) => {
150
+ // Web routes use session cookie
151
+ if (req.path?.startsWith('/web/')) {
152
+ return req.cookies?.session_token || null;
153
+ }
154
+ // API routes use Bearer token (default)
155
+ return undefined;
156
+ }
157
+ })
158
+ ```
159
+
160
+ ### Example: Custom Header for Webhooks
161
+
162
+ ```typescript
163
+ jwtAuthPlugin({
164
+ secret: process.env.JWT_SECRET!,
165
+ getUser: async (tokenData) => { /* ... */ },
166
+ rolePermissions: { /* ... */ },
167
+ tokenExtractor: (req) => {
168
+ // Webhook endpoints use custom header
169
+ if (req.path?.startsWith('/webhooks/')) {
170
+ return req.headers['x-webhook-signature'] as string || null;
171
+ }
172
+ // Other routes use Bearer token
173
+ return undefined;
174
+ }
175
+ })
176
+ ```
177
+
178
+ ### Example: Method-Based Extraction
179
+
180
+ ```typescript
181
+ jwtAuthPlugin({
182
+ secret: process.env.JWT_SECRET!,
183
+ getUser: async (tokenData) => { /* ... */ },
184
+ rolePermissions: { /* ... */ },
185
+ tokenExtractor: (req) => {
186
+ // Special handling for PATCH requests
187
+ if (req.method === 'PATCH') {
188
+ return req.headers['x-patch-token'] as string || null;
189
+ }
190
+ // All other methods use Bearer
191
+ return undefined;
192
+ }
193
+ })
194
+ ```
195
+
196
+ ### Example: Multiple Fallbacks
197
+
198
+ ```typescript
199
+ jwtAuthPlugin({
200
+ secret: process.env.JWT_SECRET!,
201
+ getUser: async (tokenData) => { /* ... */ },
202
+ rolePermissions: { /* ... */ },
203
+ tokenExtractor: (req) => {
204
+ // Try cookie first for browser requests
205
+ if (req.headers['user-agent']?.includes('Mozilla')) {
206
+ const cookieToken = req.cookies?.auth_token;
207
+ if (cookieToken) return cookieToken;
208
+ }
209
+
210
+ // Try query param for mobile apps
211
+ if (req.query?.token) {
212
+ return req.query.token as string;
213
+ }
214
+
215
+ // Fall back to default Bearer token extraction
216
+ return undefined;
217
+ }
218
+ })
219
+ ```
220
+
221
+ ### Important Notes
222
+
223
+ - When `tokenExtractor` returns `undefined`, the plugin falls back to extracting from `Authorization: Bearer <token>`
224
+ - When it returns `null`, authentication fails immediately (useful to enforce specific auth methods for certain routes)
225
+ - When it returns a `string`, that token is validated using the same JWT verification logic
226
+ - The callback has access to `req.path`, `req.method`, `req.headers`, `req.query`, `req.cookies`, etc.
227
+
228
+ ## Dynamic Permissions with Database
229
+
230
+ 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.
231
+
232
+ ### When to Use Dynamic Permissions
233
+
234
+ Use `checkPermissions` when:
235
+ - Permissions are stored in the database per user or per role
236
+ - Permissions can change without restarting the application
237
+ - Different organizations/tenants have different permission sets
238
+ - You need fine-grained, user-specific permissions
239
+
240
+ ### How It Works
241
+
242
+ When you provide `checkPermissions`:
243
+ 1. Token is extracted and decoded (same as before)
244
+ 2. Static `rolePermissions` check is **skipped**
245
+ 3. `getUser` is called - this is where you fetch permissions from DB
246
+ 4. `checkPermissions` is called with the user object and required route permissions
247
+ 5. If `checkPermissions` returns `true`, authentication succeeds
248
+
249
+ ### Basic Example
250
+
251
+ ```typescript
252
+ jwtAuthPlugin({
253
+ secret: process.env.JWT_SECRET!,
254
+
255
+ getUser: async (tokenData) => {
256
+ // Fetch user from database
257
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
258
+
259
+ // Fetch user's permissions from database
260
+ const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
261
+
262
+ return {
263
+ id: user._id,
264
+ username: user.username,
265
+ roles: user.roles,
266
+ permissions, // Attach permissions to user object
267
+ };
268
+ },
269
+
270
+ // rolePermissions can be empty when using dynamic permissions
271
+ rolePermissions: {},
272
+
273
+ // Custom permission checker
274
+ checkPermissions: async (user, routePermissions) => {
275
+ // User must have ALL required permissions
276
+ return routePermissions.every(perm =>
277
+ user.permissions?.includes(perm)
278
+ );
279
+ },
280
+ })
281
+ ```
282
+
283
+ ### Multi-Tenant Example
284
+
285
+ ```typescript
286
+ jwtAuthPlugin({
287
+ secret: process.env.JWT_SECRET!,
288
+
289
+ getUser: async (tokenData) => {
290
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
291
+
292
+ // Fetch permissions based on user's organization
293
+ const permissions = await ctx.repos.permissionRepo.getOrgPermissions(
294
+ user._id,
295
+ user.organizationId
296
+ );
297
+
298
+ return {
299
+ id: user._id,
300
+ username: user.username,
301
+ organizationId: user.organizationId,
302
+ permissions,
303
+ };
304
+ },
305
+
306
+ rolePermissions: {},
307
+
308
+ checkPermissions: async (user, routePermissions) => {
309
+ return routePermissions.every(perm =>
310
+ user.permissions?.includes(perm)
311
+ );
312
+ },
313
+ })
314
+ ```
315
+
316
+ ### Hybrid: Role-Based + User-Specific Permissions
317
+
318
+ ```typescript
319
+ jwtAuthPlugin({
320
+ secret: process.env.JWT_SECRET!,
321
+
322
+ getUser: async (tokenData) => {
323
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
324
+
325
+ // Get base permissions from roles
326
+ const rolePerms = await ctx.repos.roleRepo.getRolePermissions(user.roles);
327
+
328
+ // Get user-specific permission overrides
329
+ const userPerms = await ctx.repos.permissionRepo.getUserPermissions(user._id);
330
+
331
+ // Combine both
332
+ const allPermissions = [...new Set([...rolePerms, ...userPerms])];
333
+
334
+ return {
335
+ id: user._id,
336
+ username: user.username,
337
+ roles: user.roles,
338
+ permissions: allPermissions,
339
+ };
340
+ },
341
+
342
+ rolePermissions: {},
343
+
344
+ checkPermissions: async (user, routePermissions) => {
345
+ return routePermissions.every(perm =>
346
+ user.permissions?.includes(perm)
347
+ );
348
+ },
349
+ })
350
+ ```
351
+
352
+ ### Permission with Wildcards
353
+
354
+ ```typescript
355
+ checkPermissions: async (user, routePermissions) => {
356
+ // Support wildcard permissions
357
+ if (user.permissions?.includes("*")) {
358
+ return true; // User has all permissions
359
+ }
360
+
361
+ // Check specific permissions
362
+ return routePermissions.every(perm =>
363
+ user.permissions?.includes(perm)
364
+ );
365
+ }
366
+ ```
367
+
368
+ ### Permission with OR Logic
369
+
370
+ ```typescript
371
+ checkPermissions: async (user, routePermissions) => {
372
+ // User needs ANY of the route permissions (OR logic)
373
+ return routePermissions.some(perm =>
374
+ user.permissions?.includes(perm)
375
+ );
376
+ }
377
+ ```
378
+
379
+ ### Caching Permissions for Performance
380
+
381
+ To reduce database load, you can cache permissions:
382
+
383
+ ```typescript
384
+ const permissionCache = new Map();
385
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
386
+
387
+ jwtAuthPlugin({
388
+ secret: process.env.JWT_SECRET!,
389
+
390
+ getUser: async (tokenData) => {
391
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
392
+
393
+ // Check cache first
394
+ const cacheKey = `perms:${user._id}`;
395
+ const cached = permissionCache.get(cacheKey);
396
+
397
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
398
+ return {
399
+ id: user._id,
400
+ username: user.username,
401
+ permissions: cached.permissions,
402
+ };
403
+ }
404
+
405
+ // Fetch from DB
406
+ const permissions = await ctx.repos.permissionRepo.getUserPermissions(user._id);
407
+
408
+ // Cache it
409
+ permissionCache.set(cacheKey, {
410
+ permissions,
411
+ timestamp: Date.now(),
412
+ });
413
+
414
+ return {
415
+ id: user._id,
416
+ username: user.username,
417
+ permissions,
418
+ };
419
+ },
420
+
421
+ rolePermissions: {},
422
+ checkPermissions: async (user, routePermissions) => {
423
+ return routePermissions.every(perm => user.permissions?.includes(perm));
424
+ },
425
+ })
426
+ ```
427
+
428
+ ### Dynamic Permissions Notes
429
+
430
+ - **Backward Compatible**: If you don't provide `checkPermissions`, static `rolePermissions` are used (existing behavior)
431
+ - **Performance**: `checkPermissions` is called on every authenticated request, so ensure `getUser` is optimized (consider caching)
432
+ - **User Object**: The `user` parameter in `checkPermissions` is the exact object returned from `getUser`
433
+ - **Public Routes**: If a route has no permissions (`[]`), `checkPermissions` is NOT called
434
+ - **Sync or Async**: `checkPermissions` can return `Promise<boolean>` or `boolean`
435
+
436
+ ### Troubleshooting Dynamic Permissions
437
+
438
+ **Issue**: Too many database queries
439
+
440
+ **Solution**: Implement permission caching in `getUser` or use an in-memory cache like Redis
441
+
442
+ **Issue**: Permissions not updating after database change
443
+
444
+ **Solution**: Clear permission cache or reduce cache TTL
445
+
446
+ ## Multi-Tenant & Dynamic Roles
447
+
448
+ The JWT auth plugin supports multi-tenant scenarios where users have different roles depending on the organization or context they're accessing. This is achieved through the `useDynamicRoles` option combined with the `req` parameter in `getUser`.
449
+
450
+ ### When to Use Dynamic Roles
451
+
452
+ Use `useDynamicRoles: true` when:
453
+ - Users belong to multiple organizations with different roles in each
454
+ - User roles are determined by request context (headers, subdomain, path)
455
+ - Roles need to be fetched from the database based on the current context
456
+ - The same user token should grant different permissions in different contexts
457
+
458
+ ### How It Works
459
+
460
+ 1. **Default behavior** (`useDynamicRoles: false`):
461
+ - Roles from the JWT token are used for permission checking
462
+ - `getUser` can modify the user object, but token roles are what matter for permissions
463
+
464
+ 2. **Dynamic roles** (`useDynamicRoles: true`):
465
+ - Permission checking uses roles from the user object returned by `getUser`
466
+ - Token roles are ignored for permission checks
467
+ - `getUser` can fetch organization-specific roles from the database
468
+
469
+ ### Basic Multi-Tenant Example
470
+
471
+ ```typescript
472
+ jwtAuthPlugin({
473
+ secret: process.env.JWT_SECRET!,
474
+ useDynamicRoles: true,
475
+
476
+ getUser: async (tokenData, req) => {
477
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
478
+
479
+ // Get organization from request header
480
+ const orgId = req.headers['x-organization-id'] as string;
481
+
482
+ // Fetch user's role in this specific organization
483
+ const membership = await ctx.repos.orgMemberRepo.findOne({
484
+ userId: user._id,
485
+ organizationId: orgId
486
+ });
487
+
488
+ return {
489
+ id: user._id,
490
+ username: user.username,
491
+ organizationId: orgId,
492
+ roles: [membership.role], // Org-specific role
493
+ };
494
+ },
495
+
496
+ rolePermissions: {
497
+ admin: ["read", "write", "delete", "manage_users"],
498
+ user: ["read", "write"],
499
+ guest: ["read"],
500
+ },
501
+ })
502
+ ```
503
+
504
+ **Usage:**
505
+ ```bash
506
+ # User is admin in org1
507
+ curl -H "Authorization: Bearer <token>" \
508
+ -H "X-Organization-ID: org1" \
509
+ https://api.example.com/users
510
+ # ✓ Has admin permissions in org1
511
+
512
+ # Same user is regular user in org2
513
+ curl -H "Authorization: Bearer <token>" \
514
+ -H "X-Organization-ID: org2" \
515
+ https://api.example.com/users
516
+ # ✓ Has user permissions in org2 (cannot manage users)
517
+ ```
518
+
519
+ ### Subdomain-Based Organizations
520
+
521
+ ```typescript
522
+ jwtAuthPlugin({
523
+ secret: process.env.JWT_SECRET!,
524
+ useDynamicRoles: true,
525
+
526
+ getUser: async (tokenData, req) => {
527
+ // Extract org from subdomain (acme.yourapp.com -> "acme")
528
+ const host = req.headers.host || '';
529
+ const orgSubdomain = host.split('.')[0];
530
+
531
+ const membership = await ctx.repos.orgMemberRepo.findOne({
532
+ userId: tokenData.userId,
533
+ organizationSlug: orgSubdomain
534
+ });
535
+
536
+ if (!membership) {
537
+ return null; // User not member of this org
538
+ }
539
+
540
+ return {
541
+ id: tokenData.userId,
542
+ username: tokenData.username,
543
+ organizationSlug: orgSubdomain,
544
+ roles: [membership.role],
545
+ };
546
+ },
547
+
548
+ rolePermissions: {
549
+ owner: ["*"], // All permissions
550
+ admin: ["read", "write", "delete", "manage_users"],
551
+ member: ["read", "write"],
552
+ },
553
+ })
554
+ ```
555
+
556
+ ### Path-Based Organization Context
557
+
558
+ ```typescript
559
+ // Routes like: /orgs/:orgId/projects
560
+ jwtAuthPlugin({
561
+ secret: process.env.JWT_SECRET!,
562
+ useDynamicRoles: true,
563
+
564
+ getUser: async (tokenData, req) => {
565
+ // Extract org from path: /orgs/org123/projects
566
+ const pathMatch = req.path?.match(/^\/orgs\/([^\/]+)/);
567
+ const orgId = pathMatch?.[1];
568
+
569
+ if (!orgId) {
570
+ // Not an org-specific route, use default role
571
+ return {
572
+ id: tokenData.userId,
573
+ username: tokenData.username,
574
+ roles: ["user"],
575
+ };
576
+ }
577
+
578
+ const membership = await ctx.repos.orgMemberRepo.findOne({
579
+ userId: tokenData.userId,
580
+ organizationId: orgId
581
+ });
582
+
583
+ return {
584
+ id: tokenData.userId,
585
+ username: tokenData.username,
586
+ organizationId: orgId,
587
+ roles: [membership.role],
588
+ };
589
+ },
590
+
591
+ rolePermissions: {
592
+ admin: ["read", "write", "delete"],
593
+ member: ["read", "write"],
594
+ viewer: ["read"],
595
+ },
596
+ })
597
+ ```
598
+
599
+ ### Combining Multiple Role Sources
600
+
601
+ ```typescript
602
+ jwtAuthPlugin({
603
+ secret: process.env.JWT_SECRET!,
604
+ useDynamicRoles: true,
605
+
606
+ getUser: async (tokenData, req) => {
607
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
608
+ const orgId = req.headers['x-organization-id'] as string;
609
+
610
+ // Get org-specific role
611
+ const orgMembership = await ctx.repos.orgMemberRepo.findOne({
612
+ userId: user._id,
613
+ organizationId: orgId
614
+ });
615
+
616
+ // Get global role from user
617
+ const globalRole = user.globalRole; // e.g., "super_admin"
618
+
619
+ // Combine roles (super admins have all permissions everywhere)
620
+ const roles = globalRole === 'super_admin'
621
+ ? ['super_admin']
622
+ : [orgMembership.role];
623
+
624
+ return {
625
+ id: user._id,
626
+ username: user.username,
627
+ organizationId: orgId,
628
+ globalRole,
629
+ roles,
630
+ };
631
+ },
632
+
633
+ rolePermissions: {
634
+ super_admin: ["*"], // Global super admins
635
+ org_admin: ["read", "write", "delete", "manage_members"],
636
+ org_member: ["read", "write"],
637
+ org_guest: ["read"],
638
+ },
639
+ })
640
+ ```
641
+
642
+ ### Performance Considerations
643
+
644
+ Since `getUser` is called on every authenticated request, consider caching:
645
+
646
+ ```typescript
647
+ const roleCache = new Map();
648
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
649
+
650
+ jwtAuthPlugin({
651
+ secret: process.env.JWT_SECRET!,
652
+ useDynamicRoles: true,
653
+
654
+ getUser: async (tokenData, req) => {
655
+ const orgId = req.headers['x-organization-id'] as string;
656
+ const cacheKey = `${tokenData.userId}:${orgId}`;
657
+
658
+ // Check cache
659
+ const cached = roleCache.get(cacheKey);
660
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
661
+ return cached.user;
662
+ }
663
+
664
+ // Fetch from DB
665
+ const membership = await ctx.repos.orgMemberRepo.findOne({
666
+ userId: tokenData.userId,
667
+ organizationId: orgId
668
+ });
669
+
670
+ const user = {
671
+ id: tokenData.userId,
672
+ username: tokenData.username,
673
+ organizationId: orgId,
674
+ roles: [membership.role],
675
+ };
676
+
677
+ // Cache it
678
+ roleCache.set(cacheKey, {
679
+ user,
680
+ timestamp: Date.now(),
681
+ });
682
+
683
+ return user;
684
+ },
685
+
686
+ rolePermissions: {
687
+ admin: ["read", "write", "delete"],
688
+ member: ["read", "write"],
689
+ },
690
+ })
691
+ ```
692
+
693
+ ### Request Context Access
694
+
695
+ The `getUser` callback receives the full request object, giving you access to:
696
+
697
+ ```typescript
698
+ getUser: async (tokenData, req) => {
699
+ // Available request properties:
700
+ req.path // "/api/users"
701
+ req.method // "GET", "POST", etc.
702
+ req.headers // { "x-organization-id": "org1", ... }
703
+ req.query // { page: "1", limit: "10" }
704
+ req.body // Request body (if applicable)
705
+ req.params // URL parameters
706
+ req.cookies // Cookies (if cookie-parser middleware is used)
707
+
708
+ // Use any combination to determine context
709
+ const orgId = req.headers['x-organization-id']
710
+ || req.query.org
711
+ || req.params.orgId;
712
+
713
+ // Fetch and return user with context-specific roles
714
+ // ...
715
+ }
716
+ ```
717
+
718
+ ### Migration from Static Roles
719
+
720
+ If you have an existing app with static roles, you can migrate gradually:
721
+
722
+ **Before (static roles):**
723
+ ```typescript
724
+ jwtAuthPlugin({
725
+ secret: process.env.JWT_SECRET!,
726
+ getUser: async (tokenData, req) => {
727
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
728
+ return {
729
+ id: user._id,
730
+ username: user.username,
731
+ // Token roles are used
732
+ };
733
+ },
734
+ rolePermissions: {
735
+ admin: ["read", "write", "delete"],
736
+ user: ["read", "write"],
737
+ },
738
+ })
739
+ ```
740
+
741
+ **After (dynamic multi-tenant roles):**
742
+ ```typescript
743
+ jwtAuthPlugin({
744
+ secret: process.env.JWT_SECRET!,
745
+ useDynamicRoles: true, // ← Enable dynamic roles
746
+ getUser: async (tokenData, req) => { // ← Now has req parameter
747
+ const user = await ctx.repos.userRepo.getById(tokenData.userId);
748
+ const orgId = req.headers['x-organization-id'];
749
+
750
+ // Fetch org-specific role
751
+ const membership = await ctx.repos.orgMemberRepo.findOne({
752
+ userId: user._id,
753
+ organizationId: orgId
754
+ });
755
+
756
+ return {
757
+ id: user._id,
758
+ username: user.username,
759
+ organizationId: orgId,
760
+ roles: [membership.role], // ← Dynamic role
761
+ };
762
+ },
763
+ rolePermissions: {
764
+ admin: ["read", "write", "delete"],
765
+ user: ["read", "write"],
766
+ },
767
+ })
768
+ ```
769
+
770
+ ### Important Notes
771
+
772
+ - **Backward Compatible**: Setting `useDynamicRoles: false` (default) maintains existing behavior
773
+ - **Token Still Required**: Dynamic roles don't bypass token validation - the token must be valid
774
+ - **Organization Validation**: Always validate that the user has access to the requested organization
775
+ - **Error Handling**: Return `null` from `getUser` if user doesn't have access to the organization
776
+ - **Cache Invalidation**: Remember to invalidate role cache when user roles change in the database
777
+
778
+ ## Context API
779
+
780
+ Once configured, the plugin provides the following methods via the `auth` context:
781
+
782
+ ### `createToken(payload: any, roles: string[]): Promise<string>`
783
+
784
+ Creates a JWT token with the provided payload and roles.
785
+
786
+ ```typescript
787
+ const token = await ctx.auth.createToken(
788
+ { userId: user._id, username: user.username },
789
+ ["user"]
790
+ );
791
+ ```
792
+
793
+ **Parameters:**
794
+ - `payload`: Any data to encode in the token (typically user ID and username)
795
+ - `roles`: Array of role names assigned to the user
796
+
797
+ **Returns:** JWT token string
798
+
799
+ **Example:**
800
+ ```typescript
801
+ // In a login handler
802
+ const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
803
+ const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
804
+
805
+ if (!user) {
806
+ return { status: 401, error: { code: "invalid_credentials" } };
807
+ }
808
+
809
+ const token = await ctx.auth.createToken(
810
+ { userId: user._id, username: user.username },
811
+ user.roles
812
+ );
813
+
814
+ return {
815
+ data: {
816
+ token,
817
+ user: {
818
+ id: user._id,
819
+ username: user.username,
820
+ },
821
+ },
822
+ };
823
+ };
824
+ ```
825
+
826
+ ### `createPasswordHashAndSalt(password: string): Promise<{ hash: string; salt: string } | null>`
827
+
828
+ Generates a secure password hash and salt using bcrypt.
829
+
830
+ ```typescript
831
+ const result = await ctx.auth.createPasswordHashAndSalt("mypassword123");
832
+ if (result) {
833
+ const { hash, salt } = result;
834
+ // Save hash and salt to database
835
+ }
836
+ ```
837
+
838
+ **Parameters:**
839
+ - `password`: The plain text password to hash
840
+
841
+ **Returns:**
842
+ - Object with `hash` and `salt` if password meets policy
843
+ - `null` if password doesn't meet the configured password policy
844
+
845
+ **Security Note:** Both hash and salt must be stored in your database to validate passwords later.
846
+
847
+ **Example:**
848
+ ```typescript
849
+ // Creating a new user
850
+ const handler: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
851
+ const passwordData = await ctx.auth.createPasswordHashAndSalt(req.body.password);
852
+
853
+ if (!passwordData) {
854
+ return {
855
+ status: 400,
856
+ error: {
857
+ code: "weak_password",
858
+ message: "Password does not meet security requirements",
859
+ },
860
+ };
861
+ }
862
+
863
+ const user = await ctx.repos.userRepo.create({
864
+ username: req.body.username,
865
+ password: passwordData.hash,
866
+ salt: passwordData.salt,
867
+ roles: ["user"],
868
+ });
869
+
870
+ return { data: { userId: user._id } };
871
+ };
872
+ ```
873
+
874
+ ### `validatePassword(password: string, passwordHash: string, salt: string): Promise<boolean>`
875
+
876
+ Validates a password against a stored hash and salt.
877
+
878
+ ```typescript
879
+ const isValid = await ctx.auth.validatePassword(
880
+ "mypassword123",
881
+ user.password,
882
+ user.salt
883
+ );
884
+ ```
885
+
886
+ **Parameters:**
887
+ - `password`: Plain text password to validate
888
+ - `passwordHash`: Stored password hash from database
889
+ - `salt`: Stored salt from database
890
+
891
+ **Returns:** `true` if password matches, `false` otherwise
892
+
893
+ **Example:**
894
+ ```typescript
895
+ // In a login handler
896
+ const handler: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
897
+ const user = await ctx.repos.userRepo.findOne({ username: req.body.username });
898
+
899
+ if (!user) {
900
+ return { status: 401, error: { code: "invalid_credentials" } };
901
+ }
902
+
903
+ const isValidPassword = await ctx.auth.validatePassword(
904
+ req.body.password,
905
+ user.password,
906
+ user.salt
907
+ );
908
+
909
+ if (!isValidPassword) {
910
+ return { status: 401, error: { code: "invalid_credentials" } };
911
+ }
912
+
913
+ const token = await ctx.auth.createToken(
914
+ { userId: user._id, username: user.username },
915
+ user.roles
916
+ );
917
+
918
+ return {
919
+ data: {
920
+ token,
921
+ user: { id: user._id, username: user.username },
922
+ },
923
+ };
924
+ };
925
+ ```
926
+
927
+ ### `authenticateRequest(req: FlinkRequest, permissions: string | string[]): Promise<boolean>`
928
+
929
+ Automatically called by Flink framework to authenticate requests. You typically don't call this directly.
930
+
931
+ ## Role-Based Access Control
932
+
933
+ ### Defining Roles and Permissions
934
+
935
+ ```typescript
936
+ jwtAuthPlugin({
937
+ secret: "your-secret",
938
+ getUser: async (tokenData) => { /* ... */ },
939
+ rolePermissions: {
940
+ // Admin role can do everything
941
+ admin: ["read", "write", "delete", "manage_users", "view_analytics"],
942
+
943
+ // Regular user has limited permissions
944
+ user: ["read", "write"],
945
+
946
+ // Guest can only read
947
+ guest: ["read"],
948
+ },
949
+ })
950
+ ```
951
+
952
+ ### Protecting Routes with Permissions
953
+
954
+ Use the `permission` property in your route configuration to restrict access:
955
+
956
+ ```typescript
957
+ // Only authenticated users (any role)
958
+ export const Route: RouteProps = {
959
+ path: "/api/profile",
960
+ permission: "read", // Must have "read" permission
961
+ };
962
+
963
+ // Only admins
964
+ export const Route: RouteProps = {
965
+ path: "/api/admin/users",
966
+ permission: "manage_users", // Must have "manage_users" permission
967
+ };
968
+
969
+ // Multiple permissions (user must have at least one)
970
+ export const Route: RouteProps = {
971
+ path: "/api/content",
972
+ permission: ["read", "write"], // Must have either "read" OR "write"
973
+ };
974
+ ```
975
+
976
+ ### Accessing User in Handlers
977
+
978
+ Once authenticated, the user object is available in `req.user`:
979
+
980
+ ```typescript
981
+ const handler: Handler<Ctx, any, any> = async ({ ctx, req }) => {
982
+ // Access authenticated user
983
+ const userId = req.user?.id;
984
+ const username = req.user?.username;
985
+ const roles = req.user?.roles;
986
+
987
+ // Use user data in your logic
988
+ const data = await ctx.repos.dataRepo.findByUserId(userId);
989
+
990
+ return { data };
991
+ };
992
+ ```
993
+
994
+ ## Making Authenticated Requests
995
+
996
+ Clients must include the JWT token in the `Authorization` header:
997
+
998
+ ```
999
+ Authorization: Bearer <your-jwt-token>
12
1000
  ```
13
- npm i -S @flink-app/api-docs-plugin
1001
+
1002
+ ### Example with fetch
1003
+
1004
+ ```javascript
1005
+ const response = await fetch('https://api.example.com/profile', {
1006
+ method: 'GET',
1007
+ headers: {
1008
+ 'Authorization': `Bearer ${token}`,
1009
+ 'Content-Type': 'application/json',
1010
+ },
1011
+ });
14
1012
  ```
15
1013
 
16
- Add and configure plugin in your app startup (probable the `index.ts` in root project):
1014
+ ### Example with axios
17
1015
 
1016
+ ```javascript
1017
+ const response = await axios.get('https://api.example.com/profile', {
1018
+ headers: {
1019
+ 'Authorization': `Bearer ${token}`,
1020
+ },
1021
+ });
18
1022
  ```
19
- import { apiDocPlugin } from "@flink-app/api-docs-plugin";
1023
+
1024
+ ## Complete Example
1025
+
1026
+ ```typescript
1027
+ // index.ts
1028
+ import { FlinkApp } from "@flink-app/flink";
1029
+ import { jwtAuthPlugin } from "@flink-app/jwt-auth-plugin";
1030
+ import { Ctx } from "./Ctx";
20
1031
 
21
1032
  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();
1033
+ const app = new FlinkApp<Ctx>({
1034
+ name: "My App",
1035
+ auth: jwtAuthPlugin({
1036
+ secret: process.env.JWT_SECRET!,
1037
+ getUser: async (tokenData) => {
1038
+ const user = await app.ctx.repos.userRepo.findById(tokenData.userId);
1039
+ return {
1040
+ id: user._id,
1041
+ username: user.username,
1042
+ roles: user.roles,
1043
+ };
1044
+ },
1045
+ rolePermissions: {
1046
+ admin: ["read", "write", "delete", "manage_users"],
1047
+ user: ["read", "write"],
1048
+ },
1049
+ passwordPolicy: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*?&]{10,}$/,
1050
+ tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
1051
+ }),
1052
+ db: {
1053
+ uri: process.env.MONGODB_URI!,
1054
+ },
1055
+ });
1056
+
1057
+ app.start();
1058
+ }
1059
+
1060
+ start();
1061
+
1062
+ // handlers/auth/PostLogin.ts
1063
+ import { Handler, RouteProps } from "@flink-app/flink";
1064
+ import { Ctx } from "../../Ctx";
1065
+ import LoginReq from "../../schemas/LoginReq";
1066
+ import LoginRes from "../../schemas/LoginRes";
1067
+
1068
+ export const Route: RouteProps = {
1069
+ path: "/auth/login",
1070
+ };
1071
+
1072
+ const PostLogin: Handler<Ctx, LoginReq, LoginRes> = async ({ ctx, req }) => {
1073
+ const { username, password } = req.body;
1074
+
1075
+ // Find user
1076
+ const user = await ctx.repos.userRepo.findOne({ username });
1077
+ if (!user) {
1078
+ return {
1079
+ status: 401,
1080
+ error: { code: "invalid_credentials", message: "Invalid username or password" },
1081
+ };
1082
+ }
1083
+
1084
+ // Validate password
1085
+ const isValid = await ctx.auth.validatePassword(password, user.password, user.salt);
1086
+ if (!isValid) {
1087
+ return {
1088
+ status: 401,
1089
+ error: { code: "invalid_credentials", message: "Invalid username or password" },
1090
+ };
1091
+ }
1092
+
1093
+ // Create token
1094
+ const token = await ctx.auth.createToken(
1095
+ { userId: user._id, username: user.username },
1096
+ user.roles
1097
+ );
1098
+
1099
+ return {
1100
+ data: {
1101
+ token,
1102
+ user: {
1103
+ id: user._id,
1104
+ username: user.username,
1105
+ roles: user.roles,
1106
+ },
1107
+ },
1108
+ };
1109
+ };
1110
+
1111
+ export default PostLogin;
1112
+
1113
+ // handlers/users/PostUser.ts
1114
+ import { Handler, RouteProps } from "@flink-app/flink";
1115
+ import { Ctx } from "../../Ctx";
1116
+ import CreateUserReq from "../../schemas/CreateUserReq";
1117
+ import CreateUserRes from "../../schemas/CreateUserRes";
1118
+
1119
+ export const Route: RouteProps = {
1120
+ path: "/users",
1121
+ permission: "manage_users", // Only admins can create users
1122
+ };
1123
+
1124
+ const PostUser: Handler<Ctx, CreateUserReq, CreateUserRes> = async ({ ctx, req }) => {
1125
+ const { username, password, roles } = req.body;
1126
+
1127
+ // Check if user exists
1128
+ const existingUser = await ctx.repos.userRepo.findOne({ username });
1129
+ if (existingUser) {
1130
+ return {
1131
+ status: 409,
1132
+ error: { code: "user_exists", message: "Username already taken" },
1133
+ };
1134
+ }
1135
+
1136
+ // Hash password
1137
+ const passwordData = await ctx.auth.createPasswordHashAndSalt(password);
1138
+ if (!passwordData) {
1139
+ return {
1140
+ status: 400,
1141
+ error: {
1142
+ code: "weak_password",
1143
+ message: "Password does not meet security requirements",
1144
+ },
1145
+ };
1146
+ }
1147
+
1148
+ // Create user
1149
+ const user = await ctx.repos.userRepo.create({
1150
+ username,
1151
+ password: passwordData.hash,
1152
+ salt: passwordData.salt,
1153
+ roles: roles || ["user"],
1154
+ createdAt: new Date(),
1155
+ });
1156
+
1157
+ return {
1158
+ data: {
1159
+ id: user._id,
1160
+ username: user.username,
1161
+ roles: user.roles,
1162
+ },
1163
+ };
1164
+ };
1165
+
1166
+ export default PostUser;
1167
+ ```
1168
+
1169
+ ## Security Best Practices
1170
+
1171
+ ### 1. Secret Key Management
1172
+
1173
+ Never hardcode your JWT secret. Use environment variables:
1174
+
1175
+ ```typescript
1176
+ jwtAuthPlugin({
1177
+ secret: process.env.JWT_SECRET!,
1178
+ // ...
1179
+ })
1180
+ ```
1181
+
1182
+ Generate a strong secret:
1183
+ ```bash
1184
+ node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
1185
+ ```
1186
+
1187
+ ### 2. Token Expiration
1188
+
1189
+ Set an appropriate TTL for your use case:
1190
+
1191
+ ```typescript
1192
+ jwtAuthPlugin({
1193
+ secret: process.env.JWT_SECRET!,
1194
+ tokenTTL: 1000 * 60 * 60 * 24 * 7, // 7 days
1195
+ // ...
1196
+ })
1197
+ ```
1198
+
1199
+ ### 3. Password Policies
1200
+
1201
+ Enforce strong password requirements:
1202
+
1203
+ ```typescript
1204
+ jwtAuthPlugin({
1205
+ secret: process.env.JWT_SECRET!,
1206
+ // Require: 12+ chars, uppercase, lowercase, number, special char
1207
+ passwordPolicy: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,}$/,
1208
+ // ...
1209
+ })
1210
+ ```
1211
+
1212
+ ### 4. HTTPS Only
1213
+
1214
+ Always use HTTPS in production to prevent token interception.
1215
+
1216
+ ### 5. Token Storage (Client-Side)
1217
+
1218
+ - Avoid `localStorage` (vulnerable to XSS)
1219
+ - Prefer `httpOnly` cookies or secure session storage
1220
+ - Implement token refresh mechanisms for long-lived sessions
1221
+
1222
+ ### 6. Rate Limiting
1223
+
1224
+ Implement rate limiting on authentication endpoints to prevent brute force attacks.
1225
+
1226
+ ## TypeScript Types
1227
+
1228
+ ```typescript
1229
+ import { JwtAuthPlugin, JwtAuthPluginOptions } from "@flink-app/jwt-auth-plugin";
1230
+
1231
+ // Token extractor callback type
1232
+ type TokenExtractor = (req: FlinkRequest) => string | null | undefined;
1233
+
1234
+ // Permission checker callback type
1235
+ type PermissionChecker = (
1236
+ user: FlinkAuthUser,
1237
+ routePermissions: string[]
1238
+ ) => Promise<boolean> | boolean;
1239
+
1240
+ // Plugin options
1241
+ interface JwtAuthPluginOptions {
1242
+ secret: string;
1243
+ algo?: jwtSimple.TAlgorithm;
1244
+ getUser: (tokenData: any) => Promise<FlinkAuthUser>;
1245
+ passwordPolicy?: RegExp;
1246
+ tokenTTL?: number;
1247
+ rolePermissions: {
1248
+ [role: string]: string[];
1249
+ };
1250
+ tokenExtractor?: TokenExtractor;
1251
+ checkPermissions?: PermissionChecker;
1252
+ }
1253
+
1254
+ // Plugin interface
1255
+ interface JwtAuthPlugin extends FlinkAuthPlugin {
1256
+ createToken: (payload: any, roles: string[]) => Promise<string>;
1257
+ createPasswordHashAndSalt: (
1258
+ password: string
1259
+ ) => Promise<{ hash: string; salt: string } | null>;
1260
+ validatePassword: (
1261
+ password: string,
1262
+ passwordHash: string,
1263
+ salt: string
1264
+ ) => Promise<boolean>;
31
1265
  }
32
1266
 
1267
+ // Authenticated user (from Flink framework)
1268
+ interface FlinkAuthUser {
1269
+ id: string;
1270
+ username?: string;
1271
+ roles?: string[];
1272
+ [key: string]: any;
1273
+ }
33
1274
  ```
1275
+
1276
+ ## Troubleshooting
1277
+
1278
+ ### Token Validation Fails
1279
+
1280
+ **Issue:** Requests return 401 Unauthorized
1281
+
1282
+ **Solutions:**
1283
+ - Verify the token is being sent in the `Authorization` header
1284
+ - Check the header format: `Authorization: Bearer <token>`
1285
+ - Ensure the secret used to sign matches the secret used to verify
1286
+ - Check if the token has expired (if TTL is configured)
1287
+
1288
+ ### Password Creation Returns Null
1289
+
1290
+ **Issue:** `createPasswordHashAndSalt` returns `null`
1291
+
1292
+ **Solution:** Password doesn't meet the configured `passwordPolicy`. Update the password or adjust the policy.
1293
+
1294
+ ### getUser Function Errors
1295
+
1296
+ **Issue:** Authentication fails with error from `getUser`
1297
+
1298
+ **Solution:** Ensure your `getUser` function properly handles missing users:
1299
+
1300
+ ```typescript
1301
+ getUser: async (tokenData) => {
1302
+ const user = await ctx.repos.userRepo.findById(tokenData.userId);
1303
+ if (!user) {
1304
+ throw new Error("User not found");
1305
+ }
1306
+ return {
1307
+ id: user._id,
1308
+ username: user.username,
1309
+ roles: user.roles,
1310
+ };
1311
+ }
1312
+ ```
1313
+
1314
+ ## License
1315
+
1316
+ MIT