@openparachute/vault 0.6.3-rc.4 → 0.6.3
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/package.json +2 -2
- package/src/hub-jwt.test.ts +80 -0
- package/src/hub-jwt.ts +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.6.3
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
29
|
-
"@openparachute/scope-guard": "^0.
|
|
29
|
+
"@openparachute/scope-guard": "^0.5.0",
|
|
30
30
|
"jose": "^6.2.2",
|
|
31
31
|
"otpauth": "^9.5.0",
|
|
32
32
|
"qrcode-terminal": "^0.12.0"
|
package/src/hub-jwt.test.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
looksLikeJwt,
|
|
22
22
|
getHubOrigin,
|
|
23
23
|
getJwksOrigin,
|
|
24
|
+
parseHubOrigins,
|
|
24
25
|
} from "./hub-jwt.ts";
|
|
25
26
|
|
|
26
27
|
interface Keypair {
|
|
@@ -121,6 +122,7 @@ let fixture: JwksFixture;
|
|
|
121
122
|
let kp: Keypair;
|
|
122
123
|
let prevHubOrigin: string | undefined;
|
|
123
124
|
let prevJwksOrigin: string | undefined;
|
|
125
|
+
let prevHubOrigins: string | undefined;
|
|
124
126
|
|
|
125
127
|
beforeAll(async () => {
|
|
126
128
|
fixture = startJwksFixture();
|
|
@@ -134,6 +136,8 @@ afterAll(() => {
|
|
|
134
136
|
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
135
137
|
if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
136
138
|
else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
|
|
139
|
+
if (prevHubOrigins === undefined) delete process.env.PARACHUTE_HUB_ORIGINS;
|
|
140
|
+
else process.env.PARACHUTE_HUB_ORIGINS = prevHubOrigins;
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
beforeEach(() => {
|
|
@@ -143,8 +147,12 @@ beforeEach(() => {
|
|
|
143
147
|
// the loopback default (no JWKS server there) and every case would fail.
|
|
144
148
|
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
145
149
|
prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
150
|
+
prevHubOrigins = process.env.PARACHUTE_HUB_ORIGINS;
|
|
146
151
|
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
147
152
|
process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
|
|
153
|
+
// Default each case to the single-origin (env-unset) world so the multi-origin
|
|
154
|
+
// iss-set is opt-in per test — unrelated cases stay byte-identical to before.
|
|
155
|
+
delete process.env.PARACHUTE_HUB_ORIGINS;
|
|
148
156
|
fixture.setUnreachable(false);
|
|
149
157
|
fixture.setKeys([kp]);
|
|
150
158
|
resetJwksCache();
|
|
@@ -168,6 +176,28 @@ describe("looksLikeJwt", () => {
|
|
|
168
176
|
});
|
|
169
177
|
});
|
|
170
178
|
|
|
179
|
+
describe("parseHubOrigins — multi-origin iss-set (hub#692)", () => {
|
|
180
|
+
test("undefined → [] (back-compat: env unset collapses to single hubOrigin)", () => {
|
|
181
|
+
expect(parseHubOrigins(undefined)).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("empty string → []", () => {
|
|
185
|
+
expect(parseHubOrigins("")).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("splits, trims, strips trailing slash, drops empties, dedupes", () => {
|
|
189
|
+
// "a,b/, ,a" → [a, b]: trailing slash off b, blank entry dropped, dup a collapsed.
|
|
190
|
+
expect(parseHubOrigins("https://a.example,https://b.example/, ,https://a.example")).toEqual([
|
|
191
|
+
"https://a.example",
|
|
192
|
+
"https://b.example",
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("whitespace-only entries are dropped", () => {
|
|
197
|
+
expect(parseHubOrigins(" , , ")).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
171
201
|
describe("origin resolvers — iss/jwks split (vault#464)", () => {
|
|
172
202
|
test("getHubOrigin honors PARACHUTE_HUB_ORIGIN (iss-validation origin)", () => {
|
|
173
203
|
process.env.PARACHUTE_HUB_ORIGIN = "https://vault.example.com/";
|
|
@@ -226,6 +256,56 @@ describe("origin resolvers — iss/jwks split (vault#464)", () => {
|
|
|
226
256
|
});
|
|
227
257
|
});
|
|
228
258
|
|
|
259
|
+
describe("validateHubJwt — multi-origin iss-set (hub#692)", () => {
|
|
260
|
+
// A second + third origin that are NOT the canonical PARACHUTE_HUB_ORIGIN.
|
|
261
|
+
// Every token here is signed by the SAME published key (`kp`), so the
|
|
262
|
+
// signature always verifies — the ONLY variable under test is whether the
|
|
263
|
+
// token's `iss` is in the accepted set. JWKS + revocation stay served by the
|
|
264
|
+
// default `fixture`, reached as both the iss origin and the jwks origin.
|
|
265
|
+
const SECOND = "https://second.example";
|
|
266
|
+
const THIRD = "https://third.example";
|
|
267
|
+
|
|
268
|
+
test("token issued by a SECOND origin validates when that origin is in PARACHUTE_HUB_ORIGINS", async () => {
|
|
269
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin; // canonical (also JWKS host)
|
|
270
|
+
process.env.PARACHUTE_HUB_ORIGINS = `${fixture.origin},${SECOND}`;
|
|
271
|
+
resetJwksCache();
|
|
272
|
+
const token = await signJwt(kp, { iss: SECOND });
|
|
273
|
+
const claims = await validateHubJwt(token);
|
|
274
|
+
expect(claims.sub).toBe("user-1");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("the canonical origin still validates when PARACHUTE_HUB_ORIGINS lists a different second origin", async () => {
|
|
278
|
+
// The resolved hubOrigin is always added to the set, so a token minted under
|
|
279
|
+
// the canonical origin keeps validating even when the env names only others.
|
|
280
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
281
|
+
process.env.PARACHUTE_HUB_ORIGINS = SECOND; // env need not include the canonical
|
|
282
|
+
resetJwksCache();
|
|
283
|
+
const token = await signJwt(kp, { iss: fixture.origin });
|
|
284
|
+
const claims = await validateHubJwt(token);
|
|
285
|
+
expect(claims.sub).toBe("user-1");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("token issued by a THIRD, unlisted origin is rejected even with PARACHUTE_HUB_ORIGINS set", async () => {
|
|
289
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
290
|
+
process.env.PARACHUTE_HUB_ORIGINS = `${fixture.origin},${SECOND}`;
|
|
291
|
+
resetJwksCache();
|
|
292
|
+
const token = await signJwt(kp, { iss: THIRD });
|
|
293
|
+
await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("back-compat: with PARACHUTE_HUB_ORIGINS UNSET only the canonical origin validates; a second origin is rejected", async () => {
|
|
297
|
+
delete process.env.PARACHUTE_HUB_ORIGINS;
|
|
298
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
299
|
+
resetJwksCache();
|
|
300
|
+
// canonical iss → accepted
|
|
301
|
+
const ok = await signJwt(kp, { iss: fixture.origin });
|
|
302
|
+
expect((await validateHubJwt(ok)).sub).toBe("user-1");
|
|
303
|
+
// the same SECOND origin that WOULD pass when listed → rejected when unset
|
|
304
|
+
const bad = await signJwt(kp, { iss: SECOND });
|
|
305
|
+
await expect(validateHubJwt(bad)).rejects.toThrow(/verification failed/);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
229
309
|
describe("validateHubJwt — happy path", () => {
|
|
230
310
|
test("valid JWT with correct iss → claims surface", async () => {
|
|
231
311
|
const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
|
package/src/hub-jwt.ts
CHANGED
|
@@ -65,6 +65,37 @@ export function getJwksOrigin(): string {
|
|
|
65
65
|
return DEFAULT_HUB_LOOPBACK;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Parse the hub's comma-separated legitimate-origin SET from
|
|
70
|
+
* `PARACHUTE_HUB_ORIGINS` into a clean string array. Mirrors the hub's own
|
|
71
|
+
* `parseHubOrigins` semantics: split on `,`, trim each, strip a trailing
|
|
72
|
+
* slash, drop empties, dedupe. We write the ~6-line helper locally rather than
|
|
73
|
+
* import from a private hub path.
|
|
74
|
+
*
|
|
75
|
+
* The multi-origin iss-set (onboarding-streamline 2026-06-25, hub#692): one box
|
|
76
|
+
* reachable on several URLs at once (loopback + `<ip>.sslip.io` + a custom
|
|
77
|
+
* domain) mints tokens whose `iss` is whichever origin the request arrived on.
|
|
78
|
+
* The signing KEY is stable and origin-independent, so a token minted under
|
|
79
|
+
* URL-A must validate when this resource is reached via URL-B on the SAME box.
|
|
80
|
+
* The hub publishes its legitimate-origin set here (out-of-band, never from an
|
|
81
|
+
* unvalidated request Host); scope-guard layers it on top of the signature
|
|
82
|
+
* verify, never as a substitute for it.
|
|
83
|
+
*
|
|
84
|
+
* BACK-COMPAT INVARIANT: when `PARACHUTE_HUB_ORIGINS` is UNSET this returns
|
|
85
|
+
* `[]`. scope-guard's `resolveAcceptedIssuers` sees an empty set and collapses
|
|
86
|
+
* to the single canonical `getHubOrigin()` — byte-identical to today. A vault
|
|
87
|
+
* that never receives the new env var behaves exactly as before.
|
|
88
|
+
*/
|
|
89
|
+
export function parseHubOrigins(raw: string | undefined): string[] {
|
|
90
|
+
if (!raw) return [];
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
for (const part of raw.split(",")) {
|
|
93
|
+
const origin = part.trim().replace(/\/$/, "");
|
|
94
|
+
if (origin.length > 0) seen.add(origin);
|
|
95
|
+
}
|
|
96
|
+
return Array.from(seen);
|
|
97
|
+
}
|
|
98
|
+
|
|
68
99
|
// Process-wide guard. The resolver form lets tests flip
|
|
69
100
|
// `PARACHUTE_HUB_ORIGIN` / `PARACHUTE_HUB_JWKS_ORIGIN` between cases — the lib
|
|
70
101
|
// re-resolves on every `validateHubJwt` and `resetJwksCache` call so the
|
|
@@ -79,6 +110,12 @@ export function getJwksOrigin(): string {
|
|
|
79
110
|
const guard = createScopeGuard({
|
|
80
111
|
hubOrigin: () => getHubOrigin(),
|
|
81
112
|
jwksOrigin: () => getJwksOrigin(),
|
|
113
|
+
// Multi-origin iss-set (hub#692): accept the hub's full legitimate-origin set
|
|
114
|
+
// (published via PARACHUTE_HUB_ORIGINS), not just the single PARACHUTE_HUB_ORIGIN.
|
|
115
|
+
// Re-evaluated per call so an operator widening the box's origins is picked up
|
|
116
|
+
// without a vault restart. When the env var is unset → `[]` → scope-guard
|
|
117
|
+
// collapses to the single canonical hubOrigin (byte-identical to before).
|
|
118
|
+
allowedIssuers: () => parseHubOrigins(process.env.PARACHUTE_HUB_ORIGINS),
|
|
82
119
|
});
|
|
83
120
|
|
|
84
121
|
/**
|