@openparachute/hub 0.5.14-rc.9 → 0.6.0
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 +23 -0
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +30 -21
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +41 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-modules-ops.ts +49 -11
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-explanations.ts +46 -18
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -20,6 +20,7 @@ const PARAMS: AuthorizeFormParams = {
|
|
|
20
20
|
codeChallenge: "ch",
|
|
21
21
|
codeChallengeMethod: "S256",
|
|
22
22
|
state: "xyz",
|
|
23
|
+
resource: null,
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const CSRF = "csrf-token-fixture";
|
|
@@ -50,9 +51,19 @@ describe("renderHiddenInputs", () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
test("escapes hostile values into hidden inputs", () => {
|
|
53
|
-
const html = renderHiddenInputs({
|
|
54
|
+
const html = renderHiddenInputs({
|
|
55
|
+
...PARAMS,
|
|
56
|
+
state: `"><script>alert(1)</script>`,
|
|
57
|
+
// RFC 8707 resource is round-tripped through a hidden input too, so a
|
|
58
|
+
// hostile value must be escaped the same way.
|
|
59
|
+
resource: `"><script>alert(2)</script>`,
|
|
60
|
+
});
|
|
54
61
|
expect(html).not.toContain("<script>alert(1)</script>");
|
|
62
|
+
expect(html).not.toContain("<script>alert(2)</script>");
|
|
55
63
|
expect(html).toContain("<script>");
|
|
64
|
+
// The resource value is emitted as a hidden input (escaped).
|
|
65
|
+
expect(html).toContain('name="resource"');
|
|
66
|
+
expect(html).toContain("alert(2)");
|
|
56
67
|
});
|
|
57
68
|
});
|
|
58
69
|
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hub#481 — `selfHealOperatorTokenIssuer` re-mints a genuine-but-stale
|
|
3
|
+
* operator token under the hub's current issuer.
|
|
4
|
+
*
|
|
5
|
+
* Background: a box that ran init/setup at loopback and was LATER exposed
|
|
6
|
+
* publicly carries an `operator.token` whose `iss` (e.g. `http://127.0.0.1:1939`)
|
|
7
|
+
* no longer matches the hub's current issuer. The hub rejects it on every CLI
|
|
8
|
+
* auth flow. The self-heal re-issues the hub's OWN credential under the new
|
|
9
|
+
* issuer, preserving scope-set + sub, gated on the token's signature verifying
|
|
10
|
+
* against this hub's current keys (no privilege-escalation surface).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the existing `operator-token.test.ts` harness shape.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
21
|
+
import {
|
|
22
|
+
OPERATOR_TOKEN_AUDIENCE,
|
|
23
|
+
OPERATOR_TOKEN_CLIENT_ID,
|
|
24
|
+
OPERATOR_TOKEN_FILENAME,
|
|
25
|
+
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
26
|
+
issueOperatorToken,
|
|
27
|
+
operatorTokenPath,
|
|
28
|
+
readOperatorTokenFile,
|
|
29
|
+
selfHealOperatorTokenIssuer,
|
|
30
|
+
writeOperatorTokenFile,
|
|
31
|
+
} from "../operator-token.ts";
|
|
32
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
33
|
+
|
|
34
|
+
interface Harness {
|
|
35
|
+
dir: string;
|
|
36
|
+
cleanup: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeHarness(): Harness {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-op-heal-"));
|
|
41
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LOOPBACK_ISSUER = "http://127.0.0.1:1939";
|
|
45
|
+
const PUBLIC_ISSUER = "https://gitcoin-parachute.unforced.dev";
|
|
46
|
+
|
|
47
|
+
describe("selfHealOperatorTokenIssuer", () => {
|
|
48
|
+
test("stale-iss genuine token + non-loopback new issuer → rotated, scope-set preserved, valid under new issuer", async () => {
|
|
49
|
+
const h = makeHarness();
|
|
50
|
+
try {
|
|
51
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
52
|
+
try {
|
|
53
|
+
rotateSigningKey(db);
|
|
54
|
+
// Mint at loopback issuer with a non-default scope-set ("start").
|
|
55
|
+
await issueOperatorToken(db, "user-abc", {
|
|
56
|
+
dir: h.dir,
|
|
57
|
+
issuer: LOOPBACK_ISSUER,
|
|
58
|
+
scopeSet: "start",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
62
|
+
issuer: PUBLIC_ISSUER,
|
|
63
|
+
configDir: h.dir,
|
|
64
|
+
});
|
|
65
|
+
expect(status.kind).toBe("rotated");
|
|
66
|
+
if (status.kind === "rotated") {
|
|
67
|
+
expect(status.scopeSet).toBe("start");
|
|
68
|
+
expect(status.path).toBe(operatorTokenPath(h.dir));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The on-disk token now has iss=PUBLIC_ISSUER, scope-set preserved,
|
|
72
|
+
// and validates under the new issuer.
|
|
73
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
74
|
+
expect(onDisk).not.toBeNull();
|
|
75
|
+
const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
|
|
76
|
+
expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
|
|
77
|
+
expect(validated.payload.sub).toBe("user-abc");
|
|
78
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
79
|
+
} finally {
|
|
80
|
+
db.close();
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
h.cleanup();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("iss already current → fresh, on-disk file byte-identical", async () => {
|
|
88
|
+
const h = makeHarness();
|
|
89
|
+
try {
|
|
90
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
91
|
+
try {
|
|
92
|
+
rotateSigningKey(db);
|
|
93
|
+
await issueOperatorToken(db, "user-abc", {
|
|
94
|
+
dir: h.dir,
|
|
95
|
+
issuer: PUBLIC_ISSUER,
|
|
96
|
+
scopeSet: "admin",
|
|
97
|
+
});
|
|
98
|
+
const before = await readFile(operatorTokenPath(h.dir));
|
|
99
|
+
|
|
100
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
101
|
+
issuer: PUBLIC_ISSUER,
|
|
102
|
+
configDir: h.dir,
|
|
103
|
+
});
|
|
104
|
+
expect(status.kind).toBe("fresh");
|
|
105
|
+
|
|
106
|
+
const after = await readFile(operatorTokenPath(h.dir));
|
|
107
|
+
expect(after.equals(before)).toBe(true);
|
|
108
|
+
} finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
h.cleanup();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("absent token file → absent, no throw", async () => {
|
|
117
|
+
const h = makeHarness();
|
|
118
|
+
try {
|
|
119
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
120
|
+
try {
|
|
121
|
+
rotateSigningKey(db);
|
|
122
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
123
|
+
issuer: PUBLIC_ISSUER,
|
|
124
|
+
configDir: h.dir,
|
|
125
|
+
});
|
|
126
|
+
expect(status.kind).toBe("absent");
|
|
127
|
+
} finally {
|
|
128
|
+
db.close();
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
h.cleanup();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("bad signature (corrupt token) → skipped:unverifiable, disk untouched", async () => {
|
|
136
|
+
const h = makeHarness();
|
|
137
|
+
try {
|
|
138
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
139
|
+
try {
|
|
140
|
+
rotateSigningKey(db);
|
|
141
|
+
// Mint a real token at loopback, then corrupt its signature segment so
|
|
142
|
+
// it no longer verifies against the hub's keys.
|
|
143
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
144
|
+
dir: h.dir,
|
|
145
|
+
issuer: LOOPBACK_ISSUER,
|
|
146
|
+
scopeSet: "start",
|
|
147
|
+
});
|
|
148
|
+
const parts = issued.token.split(".");
|
|
149
|
+
const hdr = parts[0] ?? "";
|
|
150
|
+
const body = parts[1] ?? "";
|
|
151
|
+
const sig = parts[2] ?? "";
|
|
152
|
+
// Flip a character in the MIDDLE of the signature, keeping it
|
|
153
|
+
// base64url-shaped. Corrupting the last char is unreliable: the final
|
|
154
|
+
// base64url char of an RS256 signature encodes only padding bits, so a
|
|
155
|
+
// flip there can decode to identical bytes and leave the signature
|
|
156
|
+
// valid (~25% of mints). A mid-signature char sits in a full 4-char
|
|
157
|
+
// group with no padding, so any single-char change deterministically
|
|
158
|
+
// alters the decoded bytes and invalidates the signature.
|
|
159
|
+
const mid = Math.floor(sig.length / 2);
|
|
160
|
+
const flipped = sig[mid] === "A" ? "B" : "A";
|
|
161
|
+
const tampered = `${hdr}.${body}.${sig.slice(0, mid)}${flipped}${sig.slice(mid + 1)}`;
|
|
162
|
+
await writeOperatorTokenFile(tampered, h.dir);
|
|
163
|
+
|
|
164
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
165
|
+
issuer: PUBLIC_ISSUER,
|
|
166
|
+
configDir: h.dir,
|
|
167
|
+
});
|
|
168
|
+
expect(status.kind).toBe("skipped");
|
|
169
|
+
if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
|
|
170
|
+
|
|
171
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
172
|
+
expect(onDisk).toBe(tampered);
|
|
173
|
+
} finally {
|
|
174
|
+
db.close();
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
h.cleanup();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("expired token (exp in the past) → skipped:unverifiable (jose throws), disk untouched", async () => {
|
|
182
|
+
const h = makeHarness();
|
|
183
|
+
try {
|
|
184
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
185
|
+
try {
|
|
186
|
+
rotateSigningKey(db);
|
|
187
|
+
// Mint a token that expired in the past — jose's exp check throws on
|
|
188
|
+
// validate, so the self-heal must classify it unverifiable.
|
|
189
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
190
|
+
dir: h.dir,
|
|
191
|
+
issuer: LOOPBACK_ISSUER,
|
|
192
|
+
scopeSet: "start",
|
|
193
|
+
ttlSeconds: 60,
|
|
194
|
+
now: () => new Date("2026-01-01T00:00:00Z"),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
198
|
+
issuer: PUBLIC_ISSUER,
|
|
199
|
+
configDir: h.dir,
|
|
200
|
+
});
|
|
201
|
+
expect(status.kind).toBe("skipped");
|
|
202
|
+
if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
|
|
203
|
+
|
|
204
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
205
|
+
expect(onDisk).toBe(issued.token);
|
|
206
|
+
} finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
h.cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("aud != operator (aud=scribe, valid sig, stale iss) → skipped:aud-mismatch, untouched", async () => {
|
|
215
|
+
const h = makeHarness();
|
|
216
|
+
try {
|
|
217
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
218
|
+
try {
|
|
219
|
+
rotateSigningKey(db);
|
|
220
|
+
// A hub-signed token with the WRONG audience must not be re-minted as
|
|
221
|
+
// an operator token (privilege guard).
|
|
222
|
+
const signed = await signAccessToken(db, {
|
|
223
|
+
sub: "user-abc",
|
|
224
|
+
scopes: ["scribe:transcribe"],
|
|
225
|
+
audience: "scribe",
|
|
226
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
227
|
+
issuer: LOOPBACK_ISSUER,
|
|
228
|
+
extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "admin" },
|
|
229
|
+
});
|
|
230
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
231
|
+
|
|
232
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
233
|
+
issuer: PUBLIC_ISSUER,
|
|
234
|
+
configDir: h.dir,
|
|
235
|
+
});
|
|
236
|
+
expect(status.kind).toBe("skipped");
|
|
237
|
+
if (status.kind === "skipped") expect(status.reason).toBe("aud-mismatch");
|
|
238
|
+
|
|
239
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
240
|
+
expect(onDisk).toBe(signed.token);
|
|
241
|
+
} finally {
|
|
242
|
+
db.close();
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
h.cleanup();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("missing/invalid pa_scope_set (stale iss, aud=operator) → skipped:no-scope-set, NOT widened to admin", async () => {
|
|
250
|
+
const h = makeHarness();
|
|
251
|
+
try {
|
|
252
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
253
|
+
try {
|
|
254
|
+
rotateSigningKey(db);
|
|
255
|
+
// aud=operator + stale iss + NO pa_scope_set claim. Falling back to a
|
|
256
|
+
// default scope-set would silently widen to admin (hub#224); refuse.
|
|
257
|
+
const signed = await signAccessToken(db, {
|
|
258
|
+
sub: "user-abc",
|
|
259
|
+
scopes: ["scribe:transcribe"],
|
|
260
|
+
audience: OPERATOR_TOKEN_AUDIENCE,
|
|
261
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
262
|
+
issuer: LOOPBACK_ISSUER,
|
|
263
|
+
});
|
|
264
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
265
|
+
|
|
266
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
267
|
+
issuer: PUBLIC_ISSUER,
|
|
268
|
+
configDir: h.dir,
|
|
269
|
+
});
|
|
270
|
+
expect(status.kind).toBe("skipped");
|
|
271
|
+
if (status.kind === "skipped") expect(status.reason).toBe("no-scope-set");
|
|
272
|
+
|
|
273
|
+
// On-disk file unchanged — no widening occurred.
|
|
274
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
275
|
+
expect(onDisk).toBe(signed.token);
|
|
276
|
+
} finally {
|
|
277
|
+
db.close();
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
h.cleanup();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("missing sub (stale iss, aud=operator, valid scope-set) → skipped:no-sub, untouched", async () => {
|
|
285
|
+
const h = makeHarness();
|
|
286
|
+
try {
|
|
287
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
288
|
+
try {
|
|
289
|
+
rotateSigningKey(db);
|
|
290
|
+
// aud=operator + recognized scope-set + stale iss but NO sub — we can't
|
|
291
|
+
// re-mint a token we can't attribute.
|
|
292
|
+
const signed = await signAccessToken(db, {
|
|
293
|
+
sub: "",
|
|
294
|
+
scopes: ["parachute:host:start"],
|
|
295
|
+
audience: OPERATOR_TOKEN_AUDIENCE,
|
|
296
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
297
|
+
issuer: LOOPBACK_ISSUER,
|
|
298
|
+
extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "start" },
|
|
299
|
+
});
|
|
300
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
301
|
+
|
|
302
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
303
|
+
issuer: PUBLIC_ISSUER,
|
|
304
|
+
configDir: h.dir,
|
|
305
|
+
});
|
|
306
|
+
expect(status.kind).toBe("skipped");
|
|
307
|
+
if (status.kind === "skipped") expect(status.reason).toBe("no-sub");
|
|
308
|
+
|
|
309
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
310
|
+
expect(onDisk).toBe(signed.token);
|
|
311
|
+
} finally {
|
|
312
|
+
db.close();
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
h.cleanup();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("target issuer loopback (public token on disk) → skipped:issuer-loopback, public token preserved", async () => {
|
|
320
|
+
const h = makeHarness();
|
|
321
|
+
try {
|
|
322
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
323
|
+
try {
|
|
324
|
+
rotateSigningKey(db);
|
|
325
|
+
// A good PUBLIC-issuer token; calling self-heal with a loopback target
|
|
326
|
+
// must never downgrade it.
|
|
327
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
328
|
+
dir: h.dir,
|
|
329
|
+
issuer: PUBLIC_ISSUER,
|
|
330
|
+
scopeSet: "admin",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
334
|
+
issuer: LOOPBACK_ISSUER,
|
|
335
|
+
configDir: h.dir,
|
|
336
|
+
});
|
|
337
|
+
expect(status.kind).toBe("skipped");
|
|
338
|
+
if (status.kind === "skipped") expect(status.reason).toBe("issuer-loopback");
|
|
339
|
+
|
|
340
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
341
|
+
expect(onDisk).toBe(issued.token);
|
|
342
|
+
// Still a public-issuer token.
|
|
343
|
+
const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
|
|
344
|
+
expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
|
|
345
|
+
} finally {
|
|
346
|
+
db.close();
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
h.cleanup();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("scope-set preserved verbatim — 'auth' set stays 'auth', not widened to admin", async () => {
|
|
354
|
+
const h = makeHarness();
|
|
355
|
+
try {
|
|
356
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
357
|
+
try {
|
|
358
|
+
rotateSigningKey(db);
|
|
359
|
+
await issueOperatorToken(db, "user-xyz", {
|
|
360
|
+
dir: h.dir,
|
|
361
|
+
issuer: LOOPBACK_ISSUER,
|
|
362
|
+
scopeSet: "auth",
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
366
|
+
issuer: PUBLIC_ISSUER,
|
|
367
|
+
configDir: h.dir,
|
|
368
|
+
});
|
|
369
|
+
expect(status.kind).toBe("rotated");
|
|
370
|
+
if (status.kind === "rotated") expect(status.scopeSet).toBe("auth");
|
|
371
|
+
|
|
372
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
373
|
+
const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
|
|
374
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("auth");
|
|
375
|
+
// The minted scopes are the "auth" set, NOT the admin superset.
|
|
376
|
+
expect(validated.payload.scope).toBe("parachute:host:auth");
|
|
377
|
+
} finally {
|
|
378
|
+
db.close();
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
h.cleanup();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("file path is the canonical operator.token under configDir", async () => {
|
|
386
|
+
const h = makeHarness();
|
|
387
|
+
try {
|
|
388
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
389
|
+
try {
|
|
390
|
+
rotateSigningKey(db);
|
|
391
|
+
await issueOperatorToken(db, "user-abc", {
|
|
392
|
+
dir: h.dir,
|
|
393
|
+
issuer: LOOPBACK_ISSUER,
|
|
394
|
+
scopeSet: "start",
|
|
395
|
+
});
|
|
396
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
397
|
+
issuer: PUBLIC_ISSUER,
|
|
398
|
+
configDir: h.dir,
|
|
399
|
+
});
|
|
400
|
+
if (status.kind === "rotated") {
|
|
401
|
+
expect(status.path).toBe(join(h.dir, OPERATOR_TOKEN_FILENAME));
|
|
402
|
+
} else {
|
|
403
|
+
throw new Error(`expected rotated, got ${status.kind}`);
|
|
404
|
+
}
|
|
405
|
+
} finally {
|
|
406
|
+
db.close();
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
h.cleanup();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { narrowResourceVaultScopes, resolveResourceVault } from "../resource-binding.ts";
|
|
3
|
+
|
|
4
|
+
const ORIGIN = "https://hub.example";
|
|
5
|
+
const BOUND = [ORIGIN, "http://127.0.0.1:1939"];
|
|
6
|
+
|
|
7
|
+
describe("resolveResourceVault", () => {
|
|
8
|
+
test("resolves a per-vault MCP resource to the vault name", () => {
|
|
9
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp`, BOUND)).toBe("jon");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("tolerates a trailing slash on the MCP path", () => {
|
|
13
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/`, BOUND)).toBe("jon");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("ignores query string + fragment", () => {
|
|
17
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp?x=1#y`, BOUND)).toBe("jon");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("resolves the PRM document URL to the vault name", () => {
|
|
21
|
+
expect(
|
|
22
|
+
resolveResourceVault(`${ORIGIN}/vault/jon/.well-known/oauth-protected-resource`, BOUND),
|
|
23
|
+
).toBe("jon");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("resolves against a non-issuer bound origin (loopback)", () => {
|
|
27
|
+
expect(resolveResourceVault("http://127.0.0.1:1939/vault/work/mcp", BOUND)).toBe("work");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns null for an off-origin resource (not one we front)", () => {
|
|
31
|
+
expect(resolveResourceVault("https://evil.example/vault/jon/mcp", BOUND)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("returns null for a non-vault path", () => {
|
|
35
|
+
expect(resolveResourceVault(`${ORIGIN}/scribe/mcp`, BOUND)).toBeNull();
|
|
36
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon`, BOUND)).toBeNull();
|
|
37
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/notes`, BOUND)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("returns null for absent / empty / malformed resource", () => {
|
|
41
|
+
expect(resolveResourceVault(null, BOUND)).toBeNull();
|
|
42
|
+
expect(resolveResourceVault(undefined, BOUND)).toBeNull();
|
|
43
|
+
expect(resolveResourceVault("", BOUND)).toBeNull();
|
|
44
|
+
expect(resolveResourceVault("not a url", BOUND)).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not collapse a deeper vault sub-path into the MCP shape", () => {
|
|
48
|
+
// `/vault/jon/mcp/extra` is not the canonical MCP endpoint.
|
|
49
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/extra`, BOUND)).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("rejects a vault segment that isn't a well-formed vault name (no junk mint)", () => {
|
|
53
|
+
// A crafted `resource=…/vault/%2F..%2Fadmin/mcp` decodes to `/../admin`,
|
|
54
|
+
// which is not `[a-zA-Z0-9_-]+`. Returning null falls through to the
|
|
55
|
+
// unbound flow — no narrowing, no token stamped `aud=vault./../admin`.
|
|
56
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/%2F..%2Fadmin/mcp`, BOUND)).toBeNull();
|
|
57
|
+
// Spaces / dots / slashes in the decoded name are all out of shape.
|
|
58
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/a.b/mcp`, BOUND)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns null for a malformed percent-escape in the vault segment (safeDecode catch path)", () => {
|
|
62
|
+
// `%GG` is not a valid percent-escape — `decodeURIComponent` throws; the
|
|
63
|
+
// helper must degrade to null rather than 500 the authorize handler.
|
|
64
|
+
expect(resolveResourceVault(`${ORIGIN}/vault/%GG/mcp`, BOUND)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("narrowResourceVaultScopes", () => {
|
|
69
|
+
test("narrows unnamed vault verbs to the named form", () => {
|
|
70
|
+
expect(narrowResourceVaultScopes(["vault:read", "vault:write"], "jon")).toEqual([
|
|
71
|
+
"vault:jon:read",
|
|
72
|
+
"vault:jon:write",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("leaves already-named scopes for other vaults untouched", () => {
|
|
77
|
+
expect(narrowResourceVaultScopes(["vault:other:read"], "jon")).toEqual(["vault:other:read"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("passes non-vault scopes through unchanged", () => {
|
|
81
|
+
expect(narrowResourceVaultScopes(["scribe:transcribe", "vault:read"], "jon")).toEqual([
|
|
82
|
+
"scribe:transcribe",
|
|
83
|
+
"vault:jon:read",
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("narrows the admin verb too (gate happens downstream)", () => {
|
|
88
|
+
// narrowResourceVaultScopes only rewrites shape; the non-requestable gate
|
|
89
|
+
// (`vault:<name>:admin`) blocks it afterward.
|
|
90
|
+
expect(narrowResourceVaultScopes(["vault:admin"], "jon")).toEqual(["vault:jon:admin"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("is idempotent over an already-narrowed list", () => {
|
|
94
|
+
const once = narrowResourceVaultScopes(["vault:read"], "jon");
|
|
95
|
+
expect(narrowResourceVaultScopes(once, "jon")).toEqual(once);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -60,10 +60,18 @@ describe("explainScope", () => {
|
|
|
60
60
|
expect(explainScope("vault:*:write")?.level).toBe("write");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
// Single-consent change (2026-05-29): vault:<name>:admin is now REQUESTABLE
|
|
64
|
+
// and reaches the consent screen, so explainScope MUST resolve it to the
|
|
65
|
+
// vault:admin explanation (level "admin"). This is load-bearing: it makes
|
|
66
|
+
// scopeIsAdmin("vault:<name>:admin") return true, which the same-hub and
|
|
67
|
+
// trust-by-name auto-mint gates rely on to keep admin consent-gated.
|
|
68
|
+
test("resolves a per-vault admin (vault:<name>:admin) to the vault:admin explanation", () => {
|
|
69
|
+
expect(explainScope("vault:default:admin")?.label).toBe(
|
|
70
|
+
SCOPE_EXPLANATIONS["vault:admin"]?.label,
|
|
71
|
+
);
|
|
72
|
+
expect(explainScope("vault:default:admin")?.level).toBe("admin");
|
|
73
|
+
expect(explainScope("vault:my-techne_2:admin")?.level).toBe("admin");
|
|
74
|
+
expect(explainScope("vault:*:admin")?.level).toBe("admin");
|
|
67
75
|
});
|
|
68
76
|
});
|
|
69
77
|
|
|
@@ -74,6 +82,17 @@ describe("scopeIsAdmin", () => {
|
|
|
74
82
|
expect(scopeIsAdmin("parachute:host:admin")).toBe(true);
|
|
75
83
|
});
|
|
76
84
|
|
|
85
|
+
// Single-consent change (2026-05-29): the named per-vault admin form must
|
|
86
|
+
// be recognized as admin. LOAD-BEARING — the same-hub auto-trust gate
|
|
87
|
+
// (`!hasAdminScope`) and the trust-by-client_name gate
|
|
88
|
+
// (`!requestedScopes.some(scopeIsAdmin)`) rely on this to keep a named admin
|
|
89
|
+
// grant consent-gated instead of silently auto-minting it.
|
|
90
|
+
test("true for named per-vault admin (vault:<name>:admin)", () => {
|
|
91
|
+
expect(scopeIsAdmin("vault:work:admin")).toBe(true);
|
|
92
|
+
expect(scopeIsAdmin("vault:default:admin")).toBe(true);
|
|
93
|
+
expect(scopeIsAdmin("vault:my-techne_2:admin")).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
77
96
|
test("false for non-admin and unknown scopes", () => {
|
|
78
97
|
expect(scopeIsAdmin("vault:read")).toBe(false);
|
|
79
98
|
expect(scopeIsAdmin("channel:send")).toBe(false);
|
|
@@ -120,16 +139,26 @@ describe("isRequestableScope", () => {
|
|
|
120
139
|
expect(isRequestableScope("notes:something-new")).toBe(true);
|
|
121
140
|
});
|
|
122
141
|
|
|
123
|
-
//
|
|
124
|
-
// public OAuth flow
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
expect(isRequestableScope("vault:
|
|
142
|
+
// Single-consent change (2026-05-29): per-vault admin scopes are now
|
|
143
|
+
// requestable via the public OAuth flow. The anti-privesc cap at the mint
|
|
144
|
+
// choke-point (`capScopesToUserAuthority`) keeps a non-owner from actually
|
|
145
|
+
// being granted admin — but the scope is no longer rejected up front, so
|
|
146
|
+
// Claude MCP (consenting as the owner) can mint a vault admin token.
|
|
147
|
+
test("true for any vault:<name>:admin scope (single-consent change)", () => {
|
|
148
|
+
expect(isRequestableScope("vault:default:admin")).toBe(true);
|
|
149
|
+
expect(isRequestableScope("vault:work:admin")).toBe(true);
|
|
150
|
+
expect(isRequestableScope("vault:my-techne_2:admin")).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("host-level operator scopes stay non-requestable", () => {
|
|
154
|
+
// The asymmetry the single-consent change preserved: per-vault admin is
|
|
155
|
+
// now requestable (capped at mint), but host-wide operator authority is
|
|
156
|
+
// still operator-only-mintable.
|
|
157
|
+
expect(isRequestableScope("parachute:host:admin")).toBe(false);
|
|
158
|
+
expect(isRequestableScope("parachute:host:auth")).toBe(false);
|
|
130
159
|
});
|
|
131
160
|
|
|
132
|
-
test("vault:<name>:read|write stays requestable
|
|
161
|
+
test("vault:<name>:read|write stays requestable", () => {
|
|
133
162
|
expect(isRequestableScope("vault:default:read")).toBe(true);
|
|
134
163
|
expect(isRequestableScope("vault:work:write")).toBe(true);
|
|
135
164
|
});
|