@openparachute/vault 0.4.6-rc.3 → 0.4.6
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 +41 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.ts +57 -3
- package/src/cli.ts +420 -22
- package/src/export-watch.test.ts +811 -0
- package/src/export-watch.ts +255 -0
- package/src/mcp-config.test.ts +260 -0
- package/src/mcp-install.test.ts +60 -0
- package/src/mcp-install.ts +61 -0
package/README.md
CHANGED
|
@@ -197,6 +197,9 @@ parachute-vault mcp-install --install-scope user # write ~/.claude.json top
|
|
|
197
197
|
parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
|
|
198
198
|
parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
|
|
199
199
|
parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
|
|
200
|
+
parachute-vault mcp-install --dry-run # describe the write without touching disk or the hub
|
|
201
|
+
parachute-vault mcp-config gitcoin # emit JSON for `claude -p --mcp-config "$(...)"`
|
|
202
|
+
parachute-vault mcp-config gitcoin --env-vars # template form with ${PARACHUTE_HUB_URL}/${PARACHUTE_VAULT_TOKEN}
|
|
200
203
|
|
|
201
204
|
# OAuth — owner password + 2FA
|
|
202
205
|
parachute-vault set-password # set/change the owner password (OAuth consent page)
|
|
@@ -619,8 +622,37 @@ parachute-vault mcp-install --legacy-pat
|
|
|
619
622
|
|
|
620
623
|
**Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
|
|
621
624
|
|
|
625
|
+
**Dry-run.** `parachute-vault mcp-install --dry-run` prints the write that would happen — target file, install scope, entry key, URL, auth mode — without touching disk or hitting the hub. Useful for probing the command's effect; in particular, `--help` no longer creates an empty `projects[<cwd>]` entry as a side effect, but `--dry-run` is the deliberate "tell me what you'd do" path.
|
|
626
|
+
|
|
622
627
|
**Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
|
|
623
628
|
|
|
629
|
+
#### Headless flows — `claude -p` runners (`mcp-config`)
|
|
630
|
+
|
|
631
|
+
`mcp-install` writes a persistent entry into a Claude Code config file. That's the right shape for interactive sessions, but it has two sharp edges for headless runners that spawn `claude -p` against a vault:
|
|
632
|
+
|
|
633
|
+
1. **Local-scope MCP entries don't propagate to `claude -p` subprocesses.** A project-scoped install under `projects[<cwd>].mcpServers` is visible to an *interactive* `claude` launched from that directory, but a `claude -p` invocation spawned by a Python script — even from the same directory, even with `--setting-sources user,project,local` — doesn't pick it up. Use `--install-scope user` for scripts, cron jobs, and runners; the local default is fine for interactive sessions.
|
|
634
|
+
2. **`--mcp-config` JSON is per-runner boilerplate.** Some runners prefer to inline the MCP config rather than mutate the user's Claude Code state. `parachute-vault mcp-config <vault-name>` emits exactly the JSON shape `--mcp-config` consumes:
|
|
635
|
+
|
|
636
|
+
```bash
|
|
637
|
+
# Mint or fetch a vault-scoped token first, then:
|
|
638
|
+
export PARACHUTE_VAULT_TOKEN=pvt_...
|
|
639
|
+
claude -p --mcp-config "$(parachute-vault mcp-config gitcoin)" \
|
|
640
|
+
--strict-mcp-config \
|
|
641
|
+
"Summarize the latest notes under projects/gitcoin"
|
|
642
|
+
|
|
643
|
+
# Or commit a template and expand at use-time:
|
|
644
|
+
parachute-vault mcp-config gitcoin --env-vars > .claude/mcp-gitcoin.json
|
|
645
|
+
# ...then later, when invoking claude:
|
|
646
|
+
export PARACHUTE_HUB_URL=http://127.0.0.1:1940
|
|
647
|
+
export PARACHUTE_VAULT_TOKEN=pvt_...
|
|
648
|
+
claude -p --mcp-config "$(envsubst < .claude/mcp-gitcoin.json)" \
|
|
649
|
+
--strict-mcp-config ...
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
**Note on `--env-vars` expansion.** The file-save pattern requires the caller to expand `${...}` placeholders at use-time (e.g. via `envsubst`, available on most Linux distros and via `brew install gettext` on macOS). Bare `$(cat .claude/mcp-gitcoin.json)` feeds literal `${PARACHUTE_HUB_URL}` strings into `claude -p` — the shell only expands placeholders inside the command-substitution *source*, not inside the *captured output*. `claude -p` itself doesn't expand `${...}` either, so the substitution has to happen between the file and the command.
|
|
653
|
+
|
|
654
|
+
Flags: `--token <bearer>` (alternative to `PARACHUTE_VAULT_TOKEN`); `--base-url <url>` (override the auto-detected origin, useful for tailnet-exposed hubs: `--base-url https://hub.tail.ts.net`); `--env-vars` (emit the template form with `${PARACHUTE_HUB_URL}` and `${PARACHUTE_VAULT_TOKEN}` placeholders, safe to commit). With no token and no `--env-vars`, the command exits 1 with a clear error — runners get a fail-fast.
|
|
655
|
+
|
|
624
656
|
## Data model
|
|
625
657
|
|
|
626
658
|
```
|
|
@@ -729,6 +761,15 @@ The checks, in the order they're emitted:
|
|
|
729
761
|
- **Claude Code shows no vault tools.** Check in order: (1) is the daemon up (`parachute-vault status`)? (2) does `~/.claude.json` have a `parachute-vault` entry with both `url` and a valid `Authorization` header? (3) does the URL's vault name match an existing vault? `parachute-vault doctor` catches the first two. A missing or stale `Authorization` header after a bare `vault mcp-install` is the usual culprit for #2 — see the Claude Code section of [Connecting a client](#connecting-a-client) for how to rewrite it.
|
|
730
762
|
- **Claude Desktop / Daily won't connect via OAuth.** If the owner-password prompt was skipped at `vault init`, the consent page falls back to requiring a vault token in place of the password (functional but clunky). Set one now with `parachute-vault set-password`. If 2FA is enrolled, have your authenticator app ready before starting the flow; lost TOTP access recovers via the backup codes printed at enrollment.
|
|
731
763
|
- **Scheduled backups aren't running.** On macOS: `doctor` flags `backup agent: not loaded` when `schedule` isn't `manual` but the launchd agent is missing — rerun `parachute-vault backup --schedule <freq>` to reinstall it. On Linux: systemd-timer support for backup isn't shipped yet, so `--schedule daily` silently skips the scheduler. Run `parachute-vault backup` from cron (or similar) until that lands.
|
|
764
|
+
- **Manual `curl` against `/vault/<name>/mcp` returns `406 Not Acceptable`.** The MCP HTTP transport requires both `application/json` and `text/event-stream` in the `Accept` header (it negotiates between the JSON response and the SSE streaming variant). Claude Code's `--mcp-config` http transport sets this automatically — the symptom only shows up when you probe the endpoint by hand. The fix:
|
|
765
|
+
|
|
766
|
+
```bash
|
|
767
|
+
curl -H 'Accept: application/json, text/event-stream' \
|
|
768
|
+
-H "Authorization: Bearer $VAULT_TOKEN" \
|
|
769
|
+
http://127.0.0.1:1940/vault/default/mcp
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Both media types are needed; omitting either is what triggers the 406.
|
|
732
773
|
|
|
733
774
|
### Getting help
|
|
734
775
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.4.6
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
|
|
5
5
|
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
26
|
-
"@openparachute/scope-guard": "^0.
|
|
26
|
+
"@openparachute/scope-guard": "^0.3.0",
|
|
27
27
|
"jose": "^6.2.2",
|
|
28
28
|
"otpauth": "^9.5.0",
|
|
29
29
|
"qrcode-terminal": "^0.12.0"
|
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -98,12 +98,24 @@ interface SignOpts {
|
|
|
98
98
|
ttlSeconds?: number;
|
|
99
99
|
/** Override the random jti — needed when a test wants to revoke this exact token. */
|
|
100
100
|
jti?: string;
|
|
101
|
+
/**
|
|
102
|
+
* `vault_scope` claim (multi-user Phase 1 PR 4 / scope-guard 0.3+).
|
|
103
|
+
* Undefined → omit the claim entirely (pre-PR-4 token shape; surfaces
|
|
104
|
+
* at scope-guard as `[]` = unrestricted). Provide `[]` explicitly for
|
|
105
|
+
* a hub-minted admin token; provide `["<name>"]` for a non-admin user.
|
|
106
|
+
*/
|
|
107
|
+
vaultScope?: string[];
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
104
111
|
const iat = Math.floor(Date.now() / 1000);
|
|
105
112
|
const exp = iat + (opts.ttlSeconds ?? 60);
|
|
106
|
-
|
|
113
|
+
const payload: Record<string, unknown> = {
|
|
114
|
+
scope: opts.scope,
|
|
115
|
+
client_id: "test-client",
|
|
116
|
+
};
|
|
117
|
+
if (opts.vaultScope !== undefined) payload.vault_scope = opts.vaultScope;
|
|
118
|
+
return await new SignJWT(payload)
|
|
107
119
|
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
108
120
|
.setIssuer(opts.iss)
|
|
109
121
|
.setSubject(opts.sub ?? "user-1")
|
|
@@ -319,6 +331,154 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
|
|
|
319
331
|
}
|
|
320
332
|
});
|
|
321
333
|
|
|
334
|
+
test("vault_scope=[aaron] reaching /vault/aaron → success (matching pin)", async () => {
|
|
335
|
+
// Non-admin user assigned to vault "aaron" presenting their token
|
|
336
|
+
// at their own vault. The vault_scope claim names "aaron"; the
|
|
337
|
+
// request targets "aaron"; the defense-in-depth check passes.
|
|
338
|
+
seedVault("aaron");
|
|
339
|
+
const token = await signJwt(kp, {
|
|
340
|
+
iss: fixture.origin,
|
|
341
|
+
aud: "vault.aaron",
|
|
342
|
+
scope: "vault:aaron:write",
|
|
343
|
+
vaultScope: ["aaron"],
|
|
344
|
+
});
|
|
345
|
+
const config = readVaultConfig("aaron")!;
|
|
346
|
+
const store = getVaultStore("aaron");
|
|
347
|
+
|
|
348
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
349
|
+
expect("error" in result).toBe(false);
|
|
350
|
+
if (!("error" in result)) {
|
|
351
|
+
expect(result.permission).toBe("full");
|
|
352
|
+
expect(result.scopes).toEqual(["vault:aaron:write"]);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("vault_scope=[aaron] reaching /vault/bob (cross-vault) → 403 vault_scope_mismatch", async () => {
|
|
357
|
+
// The exact threat model multi-user Phase 1 PR 5 protects against.
|
|
358
|
+
// A non-admin user pinned to "aaron" somehow gets a token naming
|
|
359
|
+
// "bob" in its scope string (mint bug, edited token, third-party RS
|
|
360
|
+
// bug, replay attack). The audience strict-check inside
|
|
361
|
+
// validateHubJwt would catch the obvious shape (aud=vault.bob
|
|
362
|
+
// reaching journal endpoint), but the case below pre-fabricates a
|
|
363
|
+
// token whose aud + scope DO name bob — only the vault_scope claim
|
|
364
|
+
// pins the user to aaron. Without the new enforcement, the request
|
|
365
|
+
// would coast through.
|
|
366
|
+
seedVault("aaron");
|
|
367
|
+
seedVault("bob");
|
|
368
|
+
const token = await signJwt(kp, {
|
|
369
|
+
iss: fixture.origin,
|
|
370
|
+
aud: "vault.bob", // audience matches bob (so the aud check passes)
|
|
371
|
+
scope: "vault:bob:write", // scope strings name bob too
|
|
372
|
+
vaultScope: ["aaron"], // but the user is pinned to aaron
|
|
373
|
+
});
|
|
374
|
+
const bobConfig = readVaultConfig("bob")!;
|
|
375
|
+
const bobStore = getVaultStore("bob");
|
|
376
|
+
|
|
377
|
+
const result = await authenticateVaultRequest(bearer(token), bobConfig, bobStore.db);
|
|
378
|
+
expect("error" in result).toBe(true);
|
|
379
|
+
if ("error" in result) {
|
|
380
|
+
expect(result.error.status).toBe(403);
|
|
381
|
+
const body = (await result.error.json()) as {
|
|
382
|
+
error: string;
|
|
383
|
+
error_type: string;
|
|
384
|
+
message: string;
|
|
385
|
+
required_vault: string;
|
|
386
|
+
granted_vault_scope?: unknown;
|
|
387
|
+
};
|
|
388
|
+
expect(body.error).toBe("Forbidden");
|
|
389
|
+
expect(body.error_type).toBe("vault_scope_mismatch");
|
|
390
|
+
// Message names both the pinned vault (aaron) and the requested
|
|
391
|
+
// vault (bob) so operators reading 403 logs can correlate to
|
|
392
|
+
// user assignment without needing to decode the token.
|
|
393
|
+
expect(body.message).toContain("aaron");
|
|
394
|
+
expect(body.message).toContain("bob");
|
|
395
|
+
expect(body.required_vault).toBe("bob");
|
|
396
|
+
// Surface hygiene: the pinned vault is intentionally NOT echoed
|
|
397
|
+
// back in a dedicated body field. The message carries enough
|
|
398
|
+
// diagnostic for an operator reading logs; a dedicated field
|
|
399
|
+
// would only leak the pin to a passive observer (the attacker
|
|
400
|
+
// already has it via local decode, but pattern hygiene says
|
|
401
|
+
// don't volunteer it).
|
|
402
|
+
expect(body.granted_vault_scope).toBeUndefined();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("vault_scope=[] (admin token) passes any-vault check", async () => {
|
|
407
|
+
// Admin tokens carry vault_scope: [] — the explicit "no per-user pin"
|
|
408
|
+
// sentinel. The defense-in-depth check skips, and the request is
|
|
409
|
+
// gated purely by audience + scope strings. Both name "work" here,
|
|
410
|
+
// so the request succeeds.
|
|
411
|
+
seedVault("work");
|
|
412
|
+
const token = await signJwt(kp, {
|
|
413
|
+
iss: fixture.origin,
|
|
414
|
+
aud: "vault.work",
|
|
415
|
+
scope: "vault:work:admin",
|
|
416
|
+
vaultScope: [],
|
|
417
|
+
});
|
|
418
|
+
const config = readVaultConfig("work")!;
|
|
419
|
+
const store = getVaultStore("work");
|
|
420
|
+
|
|
421
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
422
|
+
expect("error" in result).toBe(false);
|
|
423
|
+
if (!("error" in result)) {
|
|
424
|
+
expect(result.permission).toBe("full");
|
|
425
|
+
expect(result.scopes).toEqual(["vault:work:admin"]);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("pre-PR-4 token (vault_scope claim absent) → treated as admin (back-compat)", async () => {
|
|
430
|
+
// Every operator token + CLI-mint that existed before hub PR 4
|
|
431
|
+
// merged lacks the vault_scope claim entirely. scope-guard surfaces
|
|
432
|
+
// the absent claim as `[]`, which the helper treats as
|
|
433
|
+
// "unrestricted" — so the request goes through as long as audience
|
|
434
|
+
// + scope strings are correct. Without this back-compat, the entire
|
|
435
|
+
// pre-PR-4 fleet would 403 the moment this code shipped, which is
|
|
436
|
+
// the wrong tradeoff for a defense-in-depth check.
|
|
437
|
+
seedVault("legacy");
|
|
438
|
+
const token = await signJwt(kp, {
|
|
439
|
+
iss: fixture.origin,
|
|
440
|
+
aud: "vault.legacy",
|
|
441
|
+
scope: "vault:legacy:write",
|
|
442
|
+
// vaultScope intentionally omitted — pre-PR-4 token shape
|
|
443
|
+
});
|
|
444
|
+
const config = readVaultConfig("legacy")!;
|
|
445
|
+
const store = getVaultStore("legacy");
|
|
446
|
+
|
|
447
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
448
|
+
expect("error" in result).toBe(false);
|
|
449
|
+
if (!("error" in result)) {
|
|
450
|
+
expect(result.permission).toBe("full");
|
|
451
|
+
expect(result.scopes).toEqual(["vault:legacy:write"]);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("vault_scope check runs AFTER broad-scope check — broad scope still wins the failure mode", async () => {
|
|
456
|
+
// A token with a broad vault scope AND a vault_scope pin gets
|
|
457
|
+
// rejected for the broad scope (more diagnostic). Pinning the
|
|
458
|
+
// ordering matters because the 401 message tells the operator
|
|
459
|
+
// "your token shape is wrong" whereas 403 vault_scope_mismatch
|
|
460
|
+
// tells them "your user can't reach this vault" — those have
|
|
461
|
+
// different remediation paths.
|
|
462
|
+
seedVault("aaron");
|
|
463
|
+
const token = await signJwt(kp, {
|
|
464
|
+
iss: fixture.origin,
|
|
465
|
+
aud: "vault.aaron",
|
|
466
|
+
scope: "vault:write", // broad — should trigger 401 first
|
|
467
|
+
vaultScope: ["other-vault"], // would also fail vault_scope check
|
|
468
|
+
});
|
|
469
|
+
const config = readVaultConfig("aaron")!;
|
|
470
|
+
const store = getVaultStore("aaron");
|
|
471
|
+
|
|
472
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
473
|
+
expect("error" in result).toBe(true);
|
|
474
|
+
if ("error" in result) {
|
|
475
|
+
// 401, not 403 — broad-scope rejection takes precedence.
|
|
476
|
+
expect(result.error.status).toBe(401);
|
|
477
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
478
|
+
expect(body.message).toContain("broad vault scope");
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
322
482
|
test("revocation list unreachable on cold start → fail-closed 401 sanitized; full diagnostic routed to console.warn", async () => {
|
|
323
483
|
seedVault("journal");
|
|
324
484
|
// Hub is reachable for JWKS but the revocation endpoint 503s. Cold cache
|
package/src/auth.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
SCOPE_READ,
|
|
32
32
|
SCOPE_WRITE,
|
|
33
33
|
} from "./scopes.ts";
|
|
34
|
+
import { enforceVaultScope } from "@openparachute/scope-guard";
|
|
34
35
|
import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -241,9 +242,15 @@ export async function authenticateVaultRequest(
|
|
|
241
242
|
// JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
|
|
242
243
|
// verification inside validateHubJwt; signature checked against hub's JWKS.
|
|
243
244
|
// Audience strict-checked against `vault.<name>` so a token stamped for
|
|
244
|
-
// one vault can't reach another.
|
|
245
|
+
// one vault can't reach another. `vault_scope` claim additionally checked
|
|
246
|
+
// against `vaultConfig.name` so a per-user vault pin refuses cross-vault
|
|
247
|
+
// access even if a scope-string somehow named the wrong vault (multi-user
|
|
248
|
+
// Phase 1 defense-in-depth — see hub#283 + scope-guard 0.3.0).
|
|
245
249
|
if (looksLikeJwt(key)) {
|
|
246
|
-
return await authenticateHubJwt(key, {
|
|
250
|
+
return await authenticateHubJwt(key, {
|
|
251
|
+
expectedAudience: `vault.${vaultConfig.name}`,
|
|
252
|
+
vaultName: vaultConfig.name,
|
|
253
|
+
});
|
|
247
254
|
}
|
|
248
255
|
|
|
249
256
|
// Try vault's token DB first
|
|
@@ -319,10 +326,20 @@ export async function authenticateVaultRequest(
|
|
|
319
326
|
* here — Phase B2 settled that hub tokens always name the resource. Per-
|
|
320
327
|
* vault audience enforcement happens inside `validateHubJwt` via
|
|
321
328
|
* `opts.expectedAudience`.
|
|
329
|
+
*
|
|
330
|
+
* Per-user vault-pin enforcement (multi-user Phase 1 PR 5): when
|
|
331
|
+
* `opts.vaultName` is set, the token's `vault_scope` claim is checked
|
|
332
|
+
* against it via scope-guard's `enforceVaultScope`. Non-admin tokens
|
|
333
|
+
* (`vault_scope: [<assigned_vault>]`) refuse cross-vault access with a
|
|
334
|
+
* 403 + `vault_scope_mismatch` error code. Admin tokens (`vault_scope:
|
|
335
|
+
* []`) and pre-PR-4 tokens (claim absent → surfaced as `[]`) pass
|
|
336
|
+
* unchanged. The 403 (not 401) signals "your credential is valid but
|
|
337
|
+
* doesn't grant access to this vault" — distinct from authentication
|
|
338
|
+
* failures upstream. See hub#283 + scope-guard 0.3.0 for the mint side.
|
|
322
339
|
*/
|
|
323
340
|
async function authenticateHubJwt(
|
|
324
341
|
token: string,
|
|
325
|
-
opts: { expectedAudience: string | null },
|
|
342
|
+
opts: { expectedAudience: string | null; vaultName?: string },
|
|
326
343
|
): Promise<{ error: Response } | AuthResult> {
|
|
327
344
|
try {
|
|
328
345
|
const claims = await validateHubJwt(token, { expectedAudience: opts.expectedAudience });
|
|
@@ -338,6 +355,43 @@ async function authenticateHubJwt(
|
|
|
338
355
|
),
|
|
339
356
|
};
|
|
340
357
|
}
|
|
358
|
+
// Defense-in-depth vault-pin check (multi-user Phase 1 PR 5). Runs
|
|
359
|
+
// AFTER the broad-scope check — a malformed token gets the more-
|
|
360
|
+
// descriptive scope-shape error rather than a generic pin-mismatch.
|
|
361
|
+
// When `vaultName` is unset (no per-vault binding known at this
|
|
362
|
+
// call site), the check is skipped — the audience strict-check
|
|
363
|
+
// inside validateHubJwt is the primary pin in that case.
|
|
364
|
+
if (opts.vaultName !== undefined && !enforceVaultScope(claims, opts.vaultName)) {
|
|
365
|
+
// Invariant: by the contract of `enforceVaultScope`, the false
|
|
366
|
+
// branch requires `claims.vaultScope.length > 0` — an empty
|
|
367
|
+
// `vaultScope` always returns true. The defensive assertion
|
|
368
|
+
// pins that so a future refactor can't slip an empty-scope
|
|
369
|
+
// request into this branch (which would render an empty
|
|
370
|
+
// parenthesised list in the message and break the diagnostic).
|
|
371
|
+
// Body shape: client gets `required_vault` + a message naming
|
|
372
|
+
// the conflict. The token's pinned vault is intentionally NOT
|
|
373
|
+
// echoed back — the attacker holds the token (and can decode
|
|
374
|
+
// claims locally), so the message would only leak the pin to a
|
|
375
|
+
// legitimate operator looking at a 403 log line. They already
|
|
376
|
+
// know which user they assigned where; the required_vault is
|
|
377
|
+
// the actionable piece.
|
|
378
|
+
if (claims.vaultScope.length === 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
"unreachable: enforceVaultScope returned false on an empty vaultScope",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
error: Response.json(
|
|
385
|
+
{
|
|
386
|
+
error: "Forbidden",
|
|
387
|
+
error_type: "vault_scope_mismatch",
|
|
388
|
+
message: `token's vault_scope (${claims.vaultScope.join(", ")}) does not include the requested vault '${opts.vaultName}'`,
|
|
389
|
+
required_vault: opts.vaultName,
|
|
390
|
+
},
|
|
391
|
+
{ status: 403 },
|
|
392
|
+
),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
341
395
|
const permission: TokenPermission =
|
|
342
396
|
hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
|
|
343
397
|
? "full"
|