@flink-app/jwt-auth-plugin 2.0.0-alpha.83 → 2.0.0-alpha.85

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @flink-app/jwt-auth-plugin
2
2
 
3
+ ## 2.0.0-alpha.85
4
+
5
+ ### Minor Changes
6
+
7
+ - 179c085: Add support for trailing-segment wildcard scopes in `rolePermissions`. A role scope ending in `.*` (e.g. `"license.subscription.*"`) now matches any required route permission that starts with that prefix including the trailing dot — `"license.subscription.get"` and `"license.subscription.cancel"` match, while `"license.subscription"` and `"license.subscriptions.get"` do not. Wildcards are segment-scoped to prevent accidental privilege escalation, and only trailing `.*` is special. Existing behavior (route `"*"`, role `"*"`, exact-match scopes, empty permission arrays) is unchanged.
8
+
9
+ ### Patch Changes
10
+
11
+ - @flink-app/flink@2.0.0-alpha.85
12
+
13
+ ## 2.0.0-alpha.84
14
+
15
+ ### Patch Changes
16
+
17
+ - @flink-app/flink@2.0.0-alpha.84
18
+
3
19
  ## 2.0.0-alpha.83
4
20
 
5
21
  ### Patch Changes
@@ -2,6 +2,23 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.hasValidPermissions = hasValidPermissions;
4
4
  var flink_1 = require("@flink-app/flink");
5
+ /**
6
+ * Checks whether a single role scope entry matches a required route
7
+ * permission. Supports exact match and trailing-segment wildcards of the
8
+ * form `"foo.bar.*"`, which match any route permission that starts with
9
+ * `"foo.bar."` (segment-scoped — does NOT match `"foo.bar"` itself, and
10
+ * does NOT match `"foo.barbaz.x"`).
11
+ */
12
+ function scopeMatches(scope, routePerm) {
13
+ if (scope === routePerm)
14
+ return true;
15
+ if (scope.endsWith(".*")) {
16
+ // Keep the trailing "." so "a.*" does not match "a" or "ab.c".
17
+ var prefix = scope.slice(0, -1);
18
+ return routePerm.startsWith(prefix);
19
+ }
20
+ return false;
21
+ }
5
22
  /**
6
23
  * Checks if provided role has permission to access route
7
24
  * based on its permissions.
@@ -28,7 +45,7 @@ function hasValidPermissions(roles, rolePermissions, routePermissions) {
28
45
  return { value: true };
29
46
  }
30
47
  if (routePermissions.every(function (routePerm) {
31
- return thisRolesPermissions.includes(routePerm);
48
+ return thisRolesPermissions.some(function (scope) { return scopeMatches(scope, routePerm); });
32
49
  })) {
33
50
  return { value: true };
34
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flink-app/jwt-auth-plugin",
3
- "version": "2.0.0-alpha.83",
3
+ "version": "2.0.0-alpha.85",
4
4
  "description": "Flink plugin for JWT auth",
5
5
  "author": "joel@frost.se",
6
6
  "license": "MIT",
@@ -19,10 +19,10 @@
19
19
  "@types/node": "22.13.10",
20
20
  "ts-node": "^10.9.2",
21
21
  "tsc-watch": "^4.2.9",
22
- "@flink-app/flink": "2.0.0-alpha.83"
22
+ "@flink-app/flink": "2.0.0-alpha.85"
23
23
  },
24
24
  "peerDependencies": {
25
- "@flink-app/flink": ">=2.0.0-alpha.83"
25
+ "@flink-app/flink": ">=2.0.0-alpha.85"
26
26
  },
27
27
  "gitHead": "4243e3b3cd6d4e1ca001a61baa8436bf2bbe4113",
28
28
  "scripts": {
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:
@@ -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
  });
@@ -1,5 +1,22 @@
1
1
  import { log } from "@flink-app/flink";
2
2
 
3
+ /**
4
+ * Checks whether a single role scope entry matches a required route
5
+ * permission. Supports exact match and trailing-segment wildcards of the
6
+ * form `"foo.bar.*"`, which match any route permission that starts with
7
+ * `"foo.bar."` (segment-scoped — does NOT match `"foo.bar"` itself, and
8
+ * does NOT match `"foo.barbaz.x"`).
9
+ */
10
+ function scopeMatches(scope: string, routePerm: string): boolean {
11
+ if (scope === routePerm) return true;
12
+ if (scope.endsWith(".*")) {
13
+ // Keep the trailing "." so "a.*" does not match "a" or "ab.c".
14
+ const prefix = scope.slice(0, -1);
15
+ return routePerm.startsWith(prefix);
16
+ }
17
+ return false;
18
+ }
19
+
3
20
  /**
4
21
  * Checks if provided role has permission to access route
5
22
  * based on its permissions.
@@ -36,7 +53,7 @@ export function hasValidPermissions(
36
53
 
37
54
  if (
38
55
  routePermissions.every((routePerm) =>
39
- thisRolesPermissions.includes(routePerm)
56
+ thisRolesPermissions.some((scope) => scopeMatches(scope, routePerm))
40
57
  )
41
58
  ) {
42
59
  return true;