@flink-app/jwt-auth-plugin 1.0.0 → 2.0.0-alpha.101
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +431 -0
- package/dist/FlinkJwtAuthPlugin.d.ts +36 -9
- package/dist/FlinkJwtAuthPlugin.js +64 -13
- package/dist/PermissionValidator.js +18 -1
- package/package.json +5 -2
- package/readme.md +314 -0
- package/spec/FlinkJwtAuthPlugin.spec.ts +162 -0
- package/spec/PermissionValidator.spec.ts +92 -0
- package/src/FlinkJwtAuthPlugin.ts +115 -15
- package/src/PermissionValidator.ts +18 -1
package/readme.md
CHANGED
|
@@ -94,6 +94,44 @@ jwtAuthPlugin({
|
|
|
94
94
|
})
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
+
## Permission Matching Semantics
|
|
98
|
+
|
|
99
|
+
The static `rolePermissions` map supports three forms of matching:
|
|
100
|
+
|
|
101
|
+
1. **Exact match** — `"car.read"` in a role's scope list matches a route declaring `permissions: ["car.read"]`.
|
|
102
|
+
2. **Role superuser** — a role whose scope list contains `"*"` is granted every permission unconditionally.
|
|
103
|
+
3. **Route allows any authenticated user** — a route declaring `permissions: ["*"]` accepts any role as long as the user is authenticated.
|
|
104
|
+
4. **Suffix wildcard scopes** — a scope entry ending in `.*` (e.g. `"license.subscription.*"`) matches any required route permission whose literal string starts with the prefix including the trailing dot.
|
|
105
|
+
|
|
106
|
+
### Suffix wildcard scopes
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
rolePermissions: {
|
|
110
|
+
restricted: [
|
|
111
|
+
"license.subscription.*", // matches license.subscription.get, license.subscription.cancel, ...
|
|
112
|
+
"license.unsubscribe", // exact match
|
|
113
|
+
],
|
|
114
|
+
admin: ["*"], // every permission (superuser)
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Matching rules:
|
|
119
|
+
|
|
120
|
+
| Scope entry | Route permission | Result |
|
|
121
|
+
| ---------------------------- | -------------------------------------- | ----------- |
|
|
122
|
+
| `"license.subscription.*"` | `"license.subscription.get"` | match |
|
|
123
|
+
| `"license.subscription.*"` | `"license.subscription.cancel"` | match |
|
|
124
|
+
| `"license.subscription.*"` | `"license.subscription"` (no suffix) | **no match** — wildcard requires a segment after the prefix |
|
|
125
|
+
| `"license.subscription.*"` | `"license.subscriptions.get"` | **no match** — segment-boundary safety, prevents accidental escalation through similarly-named scopes |
|
|
126
|
+
| `"a.b.c.*"` | `"a.b.c.x.y"` | match |
|
|
127
|
+
|
|
128
|
+
Notes:
|
|
129
|
+
|
|
130
|
+
- The wildcard is **segment-scoped**, not substring-scoped. `foo.*` matches `foo.bar` and `foo.bar.baz` but never `foobar.x`.
|
|
131
|
+
- Only a **trailing** `.*` is special. Infix forms like `*.get` or `foo.*.bar` are not supported.
|
|
132
|
+
- Wildcard scopes can be mixed freely with exact-match entries in the same role.
|
|
133
|
+
- All required route permissions must be matched (the check is `every`), so a single wildcard can cover multiple required scopes only if they all fall under its prefix.
|
|
134
|
+
|
|
97
135
|
## Custom Token Extraction
|
|
98
136
|
|
|
99
137
|
By default, the plugin extracts JWT tokens from the `Authorization` header as Bearer tokens:
|
|
@@ -443,6 +481,282 @@ jwtAuthPlugin({
|
|
|
443
481
|
|
|
444
482
|
**Solution**: Clear permission cache or reduce cache TTL
|
|
445
483
|
|
|
484
|
+
## `req.token` & `req.rawToken` - Accessing Token Claims
|
|
485
|
+
|
|
486
|
+
After successful authentication the plugin attaches the verified token to the request:
|
|
487
|
+
|
|
488
|
+
- **`req.token`** - the decoded, **signature-verified** token payload (claims). Same object passed to `getUser`.
|
|
489
|
+
- **`req.rawToken`** - the original token string that authenticated the request (Bearer header or custom `tokenExtractor` output).
|
|
490
|
+
|
|
491
|
+
This lets handlers read token claims (e.g. `jti`, custom session ids) without re-extracting and re-decoding the bearer token themselves - and without an unverified decode.
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
const handler: Handler<Ctx, any, any> = async ({ req }) => {
|
|
495
|
+
// Read claims straight off the verified token
|
|
496
|
+
const { jti } = (req.token as { jti?: string }) ?? {};
|
|
497
|
+
const sessionId = jti ?? legacySessionId(req.user._id);
|
|
498
|
+
|
|
499
|
+
// Forward the raw token to a downstream service if needed
|
|
500
|
+
await downstream.call({ headers: { authorization: `Bearer ${req.rawToken}` } });
|
|
501
|
+
|
|
502
|
+
return { data: { sessionId } };
|
|
503
|
+
};
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
**Notes:**
|
|
507
|
+
|
|
508
|
+
- Both fields are only set once authentication **succeeds** (a rejected request never reaches the handler).
|
|
509
|
+
- `req.token` is the exact decoded payload, including framework-added claims like `iat` and `exp`.
|
|
510
|
+
- Signature is verified during decode; unlike a bare `jsonwebtoken.decode(...)`, you don't need to (and shouldn't) re-verify.
|
|
511
|
+
- `req.rawToken` reflects whatever `tokenExtractor` returned, so it works for cookie/query-based tokens too.
|
|
512
|
+
|
|
513
|
+
## `req.userPermissions` - AI Permissions Integration
|
|
514
|
+
|
|
515
|
+
Starting in **v1.1.0**, the JWT auth plugin automatically populates `req.userPermissions` with the resolved permissions array. This field is specifically designed for use with Flink's AI agents and tools system, providing a standardized way to handle permissions across handlers, agents, and tools.
|
|
516
|
+
|
|
517
|
+
### Why `req.userPermissions`?
|
|
518
|
+
|
|
519
|
+
**Before v1.1.0**: Tools and agents had to manually convert roles to permissions or query the database in permission functions, leading to:
|
|
520
|
+
- Duplicated role-to-permission mapping logic
|
|
521
|
+
- Performance issues (N tools × M agent steps = many DB queries)
|
|
522
|
+
- No support for multi-tenant permission contexts
|
|
523
|
+
- Inconsistent permission resolution across the app
|
|
524
|
+
|
|
525
|
+
**After v1.1.0**: The auth plugin populates `req.userPermissions` once during authentication, and this resolved array is passed through to agents and tools:
|
|
526
|
+
- ✅ Single source of truth for permissions
|
|
527
|
+
- ✅ Performance optimization (computed once per request)
|
|
528
|
+
- ✅ Reuses all auth plugin features (roles, dynamic roles, custom checkers)
|
|
529
|
+
- ✅ Consistent permission checking across handlers, agents, and tools
|
|
530
|
+
|
|
531
|
+
### How It Works
|
|
532
|
+
|
|
533
|
+
The JWT auth plugin automatically populates `req.userPermissions` based on the configuration strategy:
|
|
534
|
+
|
|
535
|
+
**Strategy 1: Direct permissions from user object**
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
jwtAuthPlugin({
|
|
539
|
+
secret: process.env.JWT_SECRET,
|
|
540
|
+
getUser: async (tokenData) => {
|
|
541
|
+
const user = await userRepo.getById(tokenData.userId);
|
|
542
|
+
return {
|
|
543
|
+
id: user.id,
|
|
544
|
+
permissions: user.permissions, // Direct permissions array
|
|
545
|
+
};
|
|
546
|
+
},
|
|
547
|
+
rolePermissions: {},
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Result: req.userPermissions = user.permissions
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
**Strategy 2: Static role mapping**
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
jwtAuthPlugin({
|
|
557
|
+
secret: process.env.JWT_SECRET,
|
|
558
|
+
getUser: async (tokenData) => {
|
|
559
|
+
return { id: tokenData.userId };
|
|
560
|
+
},
|
|
561
|
+
rolePermissions: {
|
|
562
|
+
admin: ["car:read", "car:create", "car:delete"],
|
|
563
|
+
user: ["car:read"],
|
|
564
|
+
premium: ["car:read", "car:create", "premium-features"],
|
|
565
|
+
},
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
// JWT token has roles: ["user", "premium"]
|
|
569
|
+
// Result: req.userPermissions = ["car:read", "car:create", "premium-features"]
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
**Strategy 3: Dynamic roles (multi-tenant)**
|
|
573
|
+
|
|
574
|
+
```typescript
|
|
575
|
+
jwtAuthPlugin({
|
|
576
|
+
secret: process.env.JWT_SECRET,
|
|
577
|
+
useDynamicRoles: true,
|
|
578
|
+
getUser: async (tokenData, req) => {
|
|
579
|
+
const orgId = req.headers['x-organization-id'];
|
|
580
|
+
const membership = await orgMembershipRepo.getByUserAndOrg(
|
|
581
|
+
tokenData.userId,
|
|
582
|
+
orgId
|
|
583
|
+
);
|
|
584
|
+
return {
|
|
585
|
+
id: tokenData.userId,
|
|
586
|
+
roles: [membership.role], // Org-specific role
|
|
587
|
+
};
|
|
588
|
+
},
|
|
589
|
+
rolePermissions: {
|
|
590
|
+
admin: ["car:read", "car:create", "car:delete"],
|
|
591
|
+
user: ["car:read"],
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Same user, different orgs → different permissions
|
|
596
|
+
// Org A: admin role → req.userPermissions = ["car:read", "car:create", "car:delete"]
|
|
597
|
+
// Org B: user role → req.userPermissions = ["car:read"]
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
### Using with AI Agents and Tools
|
|
601
|
+
|
|
602
|
+
Pass `req.userPermissions` to agents in your handlers:
|
|
603
|
+
|
|
604
|
+
```typescript
|
|
605
|
+
// Handler
|
|
606
|
+
const handler: Handler<AppCtx, RequestBody, ResponseBody> = async ({ req, ctx }) => {
|
|
607
|
+
const agent = ctx.agents.carAgent
|
|
608
|
+
.withUser(req.user)
|
|
609
|
+
.withPermissions(req.userPermissions); // Pass resolved permissions
|
|
610
|
+
|
|
611
|
+
return await agent.execute({ message: req.body.message });
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// Agent
|
|
615
|
+
export default class CarAgent extends FlinkAgent<AppCtx> {
|
|
616
|
+
description = "Expert in car models";
|
|
617
|
+
instructions = "You are a car expert...";
|
|
618
|
+
tools = ["get-cars-tool", "create-car-tool"];
|
|
619
|
+
permissions = "car:access"; // Agent-level permission
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Tool
|
|
623
|
+
export const Tool: FlinkToolProps = {
|
|
624
|
+
id: "create-car",
|
|
625
|
+
description: "Create a new car",
|
|
626
|
+
permissions: "car:create", // Tool-level permission
|
|
627
|
+
inputSchema: z.object({
|
|
628
|
+
brand: z.string(),
|
|
629
|
+
model: z.string(),
|
|
630
|
+
}),
|
|
631
|
+
};
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
**What happens:**
|
|
635
|
+
1. Auth plugin resolves permissions → `req.userPermissions = ["car:access", "car:read", "car:create"]`
|
|
636
|
+
2. Agent checks if user has `"car:access"` → ✅ allowed
|
|
637
|
+
3. LLM only sees tools user has permission for (`"create-car"` is shown, others hidden)
|
|
638
|
+
4. Tool execution checks `"car:create"` permission → ✅ allowed
|
|
639
|
+
|
|
640
|
+
### Permission Resolution Priority
|
|
641
|
+
|
|
642
|
+
The plugin computes `req.userPermissions` in this order:
|
|
643
|
+
|
|
644
|
+
1. **Direct permissions**: If `user.permissions` exists (from `getUser`), use it directly
|
|
645
|
+
2. **Dynamic roles**: If `useDynamicRoles: true`, map `user.roles` to permissions via `rolePermissions`
|
|
646
|
+
3. **Static roles**: Map token roles to permissions via `rolePermissions`
|
|
647
|
+
4. **Empty array**: If no permissions found, return `[]`
|
|
648
|
+
|
|
649
|
+
### Backward Compatibility
|
|
650
|
+
|
|
651
|
+
`req.userPermissions` is **optional** - existing apps continue to work:
|
|
652
|
+
|
|
653
|
+
- If not using AI agents/tools → field is populated but not used
|
|
654
|
+
- Tools/agents fall back to `user.permissions` if `req.userPermissions` is not passed
|
|
655
|
+
- No breaking changes to existing code
|
|
656
|
+
|
|
657
|
+
### Performance Benefits
|
|
658
|
+
|
|
659
|
+
**Before** (function-based permissions without `userPermissions`):
|
|
660
|
+
```typescript
|
|
661
|
+
// Permission checked N times (once per tool use)
|
|
662
|
+
permissions: async (input, user) => {
|
|
663
|
+
const perms = await fetchUserPermissions(user.id); // DB query per tool!
|
|
664
|
+
return perms.includes("car:create");
|
|
665
|
+
}
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
**After** (using `userPermissions`):
|
|
669
|
+
```typescript
|
|
670
|
+
// Permissions computed ONCE during authentication
|
|
671
|
+
req.userPermissions = ["car:read", "car:create"]; // Happens once
|
|
672
|
+
|
|
673
|
+
// Tools check the array (fast O(1) lookup)
|
|
674
|
+
permissions: "car:create"
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
**Result**: Single DB query/computation per request, regardless of how many tools or agent steps are used.
|
|
678
|
+
|
|
679
|
+
### Example: Complete Integration
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
// 1. Configure JWT auth plugin
|
|
683
|
+
jwtAuthPlugin({
|
|
684
|
+
secret: process.env.JWT_SECRET,
|
|
685
|
+
rolePermissions: {
|
|
686
|
+
admin: ["car:read", "car:create", "car:delete", "premium-features"],
|
|
687
|
+
user: ["car:read"],
|
|
688
|
+
premium: ["car:read", "car:create", "premium-features"],
|
|
689
|
+
},
|
|
690
|
+
getUser: async (tokenData) => {
|
|
691
|
+
const user = await userRepo.getById(tokenData.userId);
|
|
692
|
+
return {
|
|
693
|
+
id: user.id,
|
|
694
|
+
email: user.email,
|
|
695
|
+
// Roles from DB or token will be mapped to permissions automatically
|
|
696
|
+
};
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// 2. Handler passes userPermissions to agent
|
|
701
|
+
const handler: Handler<AppCtx, { question: string }, { answer: string }> = async ({
|
|
702
|
+
req,
|
|
703
|
+
ctx,
|
|
704
|
+
}) => {
|
|
705
|
+
const agent = ctx.agents.carAgent
|
|
706
|
+
.withUser(req.user)
|
|
707
|
+
.withPermissions(req.userPermissions); // Resolved permissions passed here
|
|
708
|
+
|
|
709
|
+
const response = await agent.execute({ message: req.body.question });
|
|
710
|
+
return { data: { answer: response.message } };
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// 3. Agent defines permissions
|
|
714
|
+
export default class CarAgent extends FlinkAgent<AppCtx> {
|
|
715
|
+
description = "Car inventory expert";
|
|
716
|
+
instructions = "Help users with car-related queries";
|
|
717
|
+
tools = ["get-cars", "create-car", "premium-report"];
|
|
718
|
+
permissions = "car:read"; // User must have car:read
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// 4. Tools define individual permissions
|
|
722
|
+
export const GetCarsTool: FlinkToolProps = {
|
|
723
|
+
id: "get-cars",
|
|
724
|
+
description: "Search car inventory",
|
|
725
|
+
permissions: "car:read", // Basic read access
|
|
726
|
+
inputSchema: z.object({ brand: z.string().optional() }),
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
export const CreateCarTool: FlinkToolProps = {
|
|
730
|
+
id: "create-car",
|
|
731
|
+
description: "Add new car to inventory",
|
|
732
|
+
permissions: "car:create", // Requires create permission
|
|
733
|
+
inputSchema: z.object({ brand: z.string(), model: z.string() }),
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
export const PremiumReportTool: FlinkToolProps = {
|
|
737
|
+
id: "premium-report",
|
|
738
|
+
description: "Generate advanced analytics",
|
|
739
|
+
permissions: ["car:read", "premium-features"], // Requires BOTH
|
|
740
|
+
inputSchema: z.object({ carId: z.string() }),
|
|
741
|
+
};
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
**Flow**:
|
|
745
|
+
1. User with JWT token containing `roles: ["user", "premium"]` makes a request
|
|
746
|
+
2. Auth plugin resolves: `req.userPermissions = ["car:read", "car:create", "premium-features"]`
|
|
747
|
+
3. Handler passes `req.userPermissions` to agent
|
|
748
|
+
4. Agent checks `"car:read"` → ✅ user has it
|
|
749
|
+
5. Agent filters tools:
|
|
750
|
+
- `get-cars` (requires `"car:read"`) → ✅ shown to LLM
|
|
751
|
+
- `create-car` (requires `"car:create"`) → ✅ shown to LLM
|
|
752
|
+
- `premium-report` (requires `["car:read", "premium-features"]`) → ✅ shown to LLM (has both)
|
|
753
|
+
6. LLM can use all three tools
|
|
754
|
+
|
|
755
|
+
If user only had `roles: ["user"]`:
|
|
756
|
+
- `req.userPermissions = ["car:read"]`
|
|
757
|
+
- Only `get-cars` tool would be shown to LLM
|
|
758
|
+
- LLM cannot hallucinate using `create-car` or `premium-report` (they're hidden)
|
|
759
|
+
|
|
446
760
|
## Multi-Tenant & Dynamic Roles
|
|
447
761
|
|
|
448
762
|
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`.
|
|
@@ -102,6 +102,62 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
102
102
|
expect(authenticated).toBeTruthy();
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
it("should expose decoded token on req.token and raw string on req.rawToken", async () => {
|
|
106
|
+
const secret = "secret";
|
|
107
|
+
const userId = "123";
|
|
108
|
+
const encodedToken = jwtSimple.encode(
|
|
109
|
+
{ id: userId, jti: "session-abc", roles: ["user"] },
|
|
110
|
+
secret
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const plugin = jwtAuthPlugin({
|
|
114
|
+
secret,
|
|
115
|
+
getUser: async ({ id }: { id: string }, req) => ({ id, username: "username" }),
|
|
116
|
+
rolePermissions: {
|
|
117
|
+
user: ["*"],
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const mockRequest = {
|
|
122
|
+
headers: {
|
|
123
|
+
authorization: "Bearer " + encodedToken,
|
|
124
|
+
},
|
|
125
|
+
} as FlinkRequest;
|
|
126
|
+
|
|
127
|
+
const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
|
|
128
|
+
|
|
129
|
+
expect(authenticated).toBeTruthy();
|
|
130
|
+
expect(mockRequest.rawToken).toBe(encodedToken);
|
|
131
|
+
expect(mockRequest.token?.jti).toBe("session-abc");
|
|
132
|
+
expect(mockRequest.token?.id).toBe(userId);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should set req.rawToken to the tokenExtractor output, not just Bearer", async () => {
|
|
136
|
+
const secret = "secret";
|
|
137
|
+
const customToken = jwtSimple.encode(
|
|
138
|
+
{ id: "1", jti: "from-query", roles: ["user"] },
|
|
139
|
+
secret
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const plugin = jwtAuthPlugin({
|
|
143
|
+
secret,
|
|
144
|
+
getUser: async ({ id }: { id: string }, req) => ({ id, username: "username" }),
|
|
145
|
+
rolePermissions: { user: ["*"] },
|
|
146
|
+
tokenExtractor: (req) => req.query?.token as string,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const mockRequest = {
|
|
150
|
+
headers: {},
|
|
151
|
+
query: { token: customToken },
|
|
152
|
+
} as unknown as FlinkRequest;
|
|
153
|
+
|
|
154
|
+
const authenticated = await plugin.authenticateRequest(mockRequest, "foo");
|
|
155
|
+
|
|
156
|
+
expect(authenticated).toBeTruthy();
|
|
157
|
+
expect(mockRequest.rawToken).toBe(customToken);
|
|
158
|
+
expect(mockRequest.token?.jti).toBe("from-query");
|
|
159
|
+
});
|
|
160
|
+
|
|
105
161
|
it("should generate token", async () => {
|
|
106
162
|
const secret = "secret";
|
|
107
163
|
const plugin = jwtAuthPlugin({
|
|
@@ -813,4 +869,110 @@ describe("FlinkJwtAuthPlugin", () => {
|
|
|
813
869
|
expect(capturedHeaders["x-custom-header"]).toBe("custom-value");
|
|
814
870
|
});
|
|
815
871
|
});
|
|
872
|
+
|
|
873
|
+
describe("resolveTokenTTL", () => {
|
|
874
|
+
it("should use resolved TTL when callback returns a number", async () => {
|
|
875
|
+
const secret = "secret";
|
|
876
|
+
const ttl = 1000 * 60 * 60 * 8; // 8 hours
|
|
877
|
+
|
|
878
|
+
const plugin = jwtAuthPlugin({
|
|
879
|
+
secret,
|
|
880
|
+
getUser: async () => ({ id: "1", username: "u" }),
|
|
881
|
+
rolePermissions: {},
|
|
882
|
+
resolveTokenTTL: () => ttl,
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const token = await plugin.createToken({ id: "1" }, ["admin"]);
|
|
886
|
+
const decoded = jwtSimple.decode(token, secret);
|
|
887
|
+
const expectedExp = Math.floor((Date.now() + ttl) / 1000);
|
|
888
|
+
|
|
889
|
+
expect(decoded.exp).toBeCloseTo(expectedExp, -1);
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("should fall back to static tokenTTL when callback returns undefined", async () => {
|
|
893
|
+
const secret = "secret";
|
|
894
|
+
const staticTTL = 1000 * 60 * 60 * 2; // 2 hours
|
|
895
|
+
|
|
896
|
+
const plugin = jwtAuthPlugin({
|
|
897
|
+
secret,
|
|
898
|
+
getUser: async () => ({ id: "1", username: "u" }),
|
|
899
|
+
rolePermissions: {},
|
|
900
|
+
tokenTTL: staticTTL,
|
|
901
|
+
resolveTokenTTL: () => undefined,
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
const token = await plugin.createToken({ id: "1" }, ["user"]);
|
|
905
|
+
const decoded = jwtSimple.decode(token, secret);
|
|
906
|
+
const expectedExp = Math.floor((Date.now() + staticTTL) / 1000);
|
|
907
|
+
|
|
908
|
+
expect(decoded.exp).toBeCloseTo(expectedExp, -1);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("should fall back to default TTL when neither option is provided", async () => {
|
|
912
|
+
const secret = "secret";
|
|
913
|
+
const hundredYears = 1000 * 60 * 60 * 24 * 365 * 100;
|
|
914
|
+
|
|
915
|
+
const plugin = jwtAuthPlugin({
|
|
916
|
+
secret,
|
|
917
|
+
getUser: async () => ({ id: "1", username: "u" }),
|
|
918
|
+
rolePermissions: {},
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const token = await plugin.createToken({ id: "1" }, ["user"]);
|
|
922
|
+
const decoded = jwtSimple.decode(token, secret);
|
|
923
|
+
const expectedExp = Math.floor((Date.now() + hundredYears) / 1000);
|
|
924
|
+
|
|
925
|
+
expect(decoded.exp).toBeCloseTo(expectedExp, -1);
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("should receive roles and payload in callback", async () => {
|
|
929
|
+
const secret = "secret";
|
|
930
|
+
let capturedRoles: string[] | undefined;
|
|
931
|
+
let capturedPayload: any;
|
|
932
|
+
|
|
933
|
+
const plugin = jwtAuthPlugin({
|
|
934
|
+
secret,
|
|
935
|
+
getUser: async () => ({ id: "1", username: "u" }),
|
|
936
|
+
rolePermissions: {},
|
|
937
|
+
resolveTokenTTL: (roles, payload) => {
|
|
938
|
+
capturedRoles = roles;
|
|
939
|
+
capturedPayload = payload;
|
|
940
|
+
return 60000;
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
await plugin.createToken({ id: "user-1", email: "a@b.com" }, ["admin", "editor"]);
|
|
945
|
+
|
|
946
|
+
expect(capturedRoles).toEqual(["admin", "editor"]);
|
|
947
|
+
expect(capturedPayload?.id).toBe("user-1");
|
|
948
|
+
expect(capturedPayload?.email).toBe("a@b.com");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("should produce different TTLs for different roles", async () => {
|
|
952
|
+
const secret = "secret";
|
|
953
|
+
|
|
954
|
+
const plugin = jwtAuthPlugin({
|
|
955
|
+
secret,
|
|
956
|
+
getUser: async () => ({ id: "1", username: "u" }),
|
|
957
|
+
rolePermissions: {},
|
|
958
|
+
resolveTokenTTL: (roles) => {
|
|
959
|
+
if (roles.includes("admin")) return 1000 * 60 * 60; // 1 hour
|
|
960
|
+
return 1000 * 60 * 60 * 24 * 30; // 30 days
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
const adminToken = await plugin.createToken({ id: "1" }, ["admin"]);
|
|
965
|
+
const userToken = await plugin.createToken({ id: "2" }, ["user"]);
|
|
966
|
+
|
|
967
|
+
const adminDecoded = jwtSimple.decode(adminToken, secret);
|
|
968
|
+
const userDecoded = jwtSimple.decode(userToken, secret);
|
|
969
|
+
|
|
970
|
+
// User token should expire much later than admin token
|
|
971
|
+
expect(userDecoded.exp).toBeGreaterThan(adminDecoded.exp);
|
|
972
|
+
|
|
973
|
+
// Difference should be roughly 30 days minus 1 hour ≈ 29.96 days in seconds
|
|
974
|
+
const diffSeconds = userDecoded.exp - adminDecoded.exp;
|
|
975
|
+
expect(diffSeconds).toBeGreaterThan(29 * 24 * 60 * 60);
|
|
976
|
+
});
|
|
977
|
+
});
|
|
816
978
|
});
|
|
@@ -69,4 +69,96 @@ describe("PermissionValidator", () => {
|
|
|
69
69
|
const valid = hasValidPermissions([], {}, ["*"]);
|
|
70
70
|
expect(valid).toBe(false);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
describe("suffix wildcard scopes", () => {
|
|
74
|
+
it("should match a single-segment suffix under the wildcard", () => {
|
|
75
|
+
const valid = hasValidPermissions(
|
|
76
|
+
["user"],
|
|
77
|
+
{ user: ["a.*"] },
|
|
78
|
+
["a.b"]
|
|
79
|
+
);
|
|
80
|
+
expect(valid).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should match a multi-segment suffix under the wildcard", () => {
|
|
84
|
+
const valid = hasValidPermissions(
|
|
85
|
+
["user"],
|
|
86
|
+
{ user: ["a.*"] },
|
|
87
|
+
["a.b.c"]
|
|
88
|
+
);
|
|
89
|
+
expect(valid).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should NOT match the wildcard prefix without a trailing segment", () => {
|
|
93
|
+
const valid = hasValidPermissions(
|
|
94
|
+
["user"],
|
|
95
|
+
{ user: ["a.*"] },
|
|
96
|
+
["a"]
|
|
97
|
+
);
|
|
98
|
+
expect(valid).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should NOT match across a segment boundary (privilege escalation guard)", () => {
|
|
102
|
+
const valid = hasValidPermissions(
|
|
103
|
+
["user"],
|
|
104
|
+
{ user: ["a.*"] },
|
|
105
|
+
["ab.c"]
|
|
106
|
+
);
|
|
107
|
+
expect(valid).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should allow mixing wildcard and exact scopes when the required perm hits the exact one", () => {
|
|
111
|
+
const valid = hasValidPermissions(
|
|
112
|
+
["user"],
|
|
113
|
+
{ user: ["a.*", "b.get"] },
|
|
114
|
+
["b.get"]
|
|
115
|
+
);
|
|
116
|
+
expect(valid).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should deny when the required perm matches neither the wildcard nor the exact entry", () => {
|
|
120
|
+
const valid = hasValidPermissions(
|
|
121
|
+
["user"],
|
|
122
|
+
{ user: ["a.*", "b.get"] },
|
|
123
|
+
["b.put"]
|
|
124
|
+
);
|
|
125
|
+
expect(valid).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should still honor route '*' when the role only has a suffix wildcard", () => {
|
|
129
|
+
const valid = hasValidPermissions(
|
|
130
|
+
["user"],
|
|
131
|
+
{ user: ["a.*"] },
|
|
132
|
+
["*"]
|
|
133
|
+
);
|
|
134
|
+
expect(valid).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should match deeply nested prefixes", () => {
|
|
138
|
+
const valid = hasValidPermissions(
|
|
139
|
+
["user"],
|
|
140
|
+
{ user: ["a.b.c.*"] },
|
|
141
|
+
["a.b.c.x.y"]
|
|
142
|
+
);
|
|
143
|
+
expect(valid).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should allow a single wildcard to cover multiple required perms", () => {
|
|
147
|
+
const valid = hasValidPermissions(
|
|
148
|
+
["user"],
|
|
149
|
+
{ user: ["a.*"] },
|
|
150
|
+
["a.b", "a.c"]
|
|
151
|
+
);
|
|
152
|
+
expect(valid).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should deny when one required perm is outside the wildcard namespace", () => {
|
|
156
|
+
const valid = hasValidPermissions(
|
|
157
|
+
["user"],
|
|
158
|
+
{ user: ["a.*"] },
|
|
159
|
+
["a.b", "c.d"]
|
|
160
|
+
);
|
|
161
|
+
expect(valid).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
72
164
|
});
|