@openparachute/vault 0.4.5 → 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 +70 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +161 -1
- package/src/auth.test.ts +235 -0
- package/src/auth.ts +135 -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/src/routing.test.ts +85 -1
- package/src/server.ts +23 -4
- package/src/vault-name.test.ts +100 -4
- package/src/vault-name.ts +61 -3
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
|
|
|
@@ -810,8 +851,37 @@ cp .env.example .env # edit with your config
|
|
|
810
851
|
docker compose up -d
|
|
811
852
|
```
|
|
812
853
|
|
|
854
|
+
Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first vault (defaults to `default`). Lowercase alphanumeric + hyphens or underscores, 2–32 chars.
|
|
855
|
+
|
|
813
856
|
### Cloud platforms
|
|
814
857
|
|
|
858
|
+
#### Render — recommended (hub-managed, v0.6)
|
|
859
|
+
|
|
860
|
+
Most users deploy vault via parachute-hub's `/admin/modules` after the
|
|
861
|
+
hub itself is on Render. See <https://parachute.computer/deploy/render/>
|
|
862
|
+
for the primary v0.6 self-host story: one Render Blueprint provisions
|
|
863
|
+
hub on a persistent disk, then you install vault (and the other
|
|
864
|
+
Parachute modules) from the hub's admin UI. The hub container
|
|
865
|
+
supervises vault's process, the shared persistent disk holds vault
|
|
866
|
+
state, and module upgrades flow through the admin UI rather than
|
|
867
|
+
separate Render redeploys.
|
|
868
|
+
|
|
869
|
+
#### Render — standalone vault (advanced)
|
|
870
|
+
|
|
871
|
+
The `render.yaml` Blueprint at the repo root deploys vault as its own
|
|
872
|
+
Render web service, separate from hub. This is the **advanced path** —
|
|
873
|
+
useful when you want vault on its own container (separate scaling,
|
|
874
|
+
isolated logs, vault-only deploy without a hub) but not what the
|
|
875
|
+
typical v0.6 self-host wants. If you're not sure, use the hub-managed
|
|
876
|
+
path above. The standalone Blueprint stays in tree because some
|
|
877
|
+
operators specifically want this shape (vault#341).
|
|
878
|
+
|
|
879
|
+
Optionally set `PARACHUTE_VAULT_NAME` to choose a name for your first
|
|
880
|
+
vault (defaults to `default`). Lowercase alphanumeric + hyphens or
|
|
881
|
+
underscores, 2–32 chars.
|
|
882
|
+
|
|
883
|
+
#### Other platforms
|
|
884
|
+
|
|
815
885
|
**Railway** ($5/mo) — Deploy from GitHub, persistent volume, public URL.
|
|
816
886
|
**Fly.io** ($3-5/mo) — `fly launch --copy-config && fly volumes create vault_data --size 1 && fly deploy`
|
|
817
887
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/vault",
|
|
3
|
-
"version": "0.4.
|
|
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.test.ts
CHANGED
|
@@ -398,3 +398,238 @@ describe("auth — legacy global YAML keys honor declared scope", () => {
|
|
|
398
398
|
}
|
|
399
399
|
});
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// VAULT_AUTH_TOKEN — server-wide operator bearer (vault#339)
|
|
404
|
+
//
|
|
405
|
+
// The container-shape auth gate. When the env var is set, a request whose
|
|
406
|
+
// `Authorization: Bearer <value>` matches authenticates as full/admin
|
|
407
|
+
// against any vault on the server — the operator-channel path for sibling
|
|
408
|
+
// services on Render where vault and hub run as separate containers and
|
|
409
|
+
// hub needs a stable shared bearer to call vault.
|
|
410
|
+
//
|
|
411
|
+
// Semantic confirmed for the loopback/non-loopback split (auth gate is
|
|
412
|
+
// orthogonal to socket-level loopback): when VAULT_AUTH_TOKEN is unset,
|
|
413
|
+
// vault's existing token surface (per-vault DB tokens + hub JWTs + legacy
|
|
414
|
+
// YAML keys) is the ONLY auth surface. The bind socket defaults to
|
|
415
|
+
// 127.0.0.1 (`VAULT_BIND` in bind.ts), but no implicit loopback trust
|
|
416
|
+
// exists at the auth layer — a request from 127.0.0.1 still has to
|
|
417
|
+
// present a valid bearer. This matches docs/auth-model.md §1.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
|
|
420
|
+
describe("auth — VAULT_AUTH_TOKEN server-wide operator bearer", () => {
|
|
421
|
+
const TOKEN = "test-operator-token-deadbeef0123456789abcdef";
|
|
422
|
+
let prevToken: string | undefined;
|
|
423
|
+
|
|
424
|
+
beforeEach(() => {
|
|
425
|
+
prevToken = process.env.VAULT_AUTH_TOKEN;
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
afterEach(() => {
|
|
429
|
+
if (prevToken === undefined) delete process.env.VAULT_AUTH_TOKEN;
|
|
430
|
+
else process.env.VAULT_AUTH_TOKEN = prevToken;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("env set + matching bearer → 200 on vault auth, full permission, admin scopes", async () => {
|
|
434
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
435
|
+
seedVault("journal");
|
|
436
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
437
|
+
const journalStore = getVaultStore("journal");
|
|
438
|
+
|
|
439
|
+
const result = await authenticateVaultRequest(
|
|
440
|
+
bearer(TOKEN),
|
|
441
|
+
journalConfig,
|
|
442
|
+
journalStore.db,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect("error" in result).toBe(false);
|
|
446
|
+
if (!("error" in result)) {
|
|
447
|
+
expect(result.permission).toBe("full");
|
|
448
|
+
expect(result.scopes).toContain("vault:admin");
|
|
449
|
+
expect(result.legacyDerived).toBe(false);
|
|
450
|
+
expect(result.scoped_tags).toBeNull();
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("env set + matching bearer authenticates against ANY vault on the server", async () => {
|
|
455
|
+
// Server-wide → not tied to any one vault's DB. Same bearer works
|
|
456
|
+
// for journal and work without minting a per-vault token in either.
|
|
457
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
458
|
+
seedVault("journal");
|
|
459
|
+
seedVault("work");
|
|
460
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
461
|
+
const journalStore = getVaultStore("journal");
|
|
462
|
+
const workConfig = readVaultConfig("work")!;
|
|
463
|
+
const workStore = getVaultStore("work");
|
|
464
|
+
|
|
465
|
+
const j = await authenticateVaultRequest(bearer(TOKEN), journalConfig, journalStore.db);
|
|
466
|
+
const w = await authenticateVaultRequest(bearer(TOKEN), workConfig, workStore.db);
|
|
467
|
+
|
|
468
|
+
expect("error" in j).toBe(false);
|
|
469
|
+
expect("error" in w).toBe(false);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("env set + missing bearer → 401 (no implicit auth)", async () => {
|
|
473
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
474
|
+
seedVault("journal");
|
|
475
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
476
|
+
const journalStore = getVaultStore("journal");
|
|
477
|
+
|
|
478
|
+
// No Authorization header at all.
|
|
479
|
+
const noBearer = new Request("https://vault.test/x");
|
|
480
|
+
const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
|
|
481
|
+
|
|
482
|
+
expect("error" in result).toBe(true);
|
|
483
|
+
if ("error" in result) {
|
|
484
|
+
expect(result.error.status).toBe(401);
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("env set + wrong bearer → 401", async () => {
|
|
489
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
490
|
+
seedVault("journal");
|
|
491
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
492
|
+
const journalStore = getVaultStore("journal");
|
|
493
|
+
|
|
494
|
+
const result = await authenticateVaultRequest(
|
|
495
|
+
bearer("wrong-token-doesnotmatch"),
|
|
496
|
+
journalConfig,
|
|
497
|
+
journalStore.db,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
expect("error" in result).toBe(true);
|
|
501
|
+
if ("error" in result) {
|
|
502
|
+
expect(result.error.status).toBe(401);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("env set + bearer that matches a vault token still resolves (server-wide first, but per-vault unchanged)", async () => {
|
|
507
|
+
// Per-vault tokens keep working even when the server-wide bearer is
|
|
508
|
+
// set. The server-wide check is a fast-path lookup before token DB
|
|
509
|
+
// resolution — a per-vault token doesn't match the env var so it
|
|
510
|
+
// falls through to the existing path.
|
|
511
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
512
|
+
seedVault("journal");
|
|
513
|
+
const perVaultToken = mintTokenInVault("journal");
|
|
514
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
515
|
+
const journalStore = getVaultStore("journal");
|
|
516
|
+
|
|
517
|
+
const result = await authenticateVaultRequest(
|
|
518
|
+
bearer(perVaultToken),
|
|
519
|
+
journalConfig,
|
|
520
|
+
journalStore.db,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
expect("error" in result).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("env unset + valid per-vault bearer → 200 (existing behavior preserved)", async () => {
|
|
527
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
528
|
+
seedVault("journal");
|
|
529
|
+
const token = mintTokenInVault("journal");
|
|
530
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
531
|
+
const journalStore = getVaultStore("journal");
|
|
532
|
+
|
|
533
|
+
const result = await authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
|
|
534
|
+
expect("error" in result).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("env unset + missing bearer → 401 (existing behavior preserved)", async () => {
|
|
538
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
539
|
+
seedVault("journal");
|
|
540
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
541
|
+
const journalStore = getVaultStore("journal");
|
|
542
|
+
|
|
543
|
+
const noBearer = new Request("https://vault.test/x");
|
|
544
|
+
const result = await authenticateVaultRequest(noBearer, journalConfig, journalStore.db);
|
|
545
|
+
expect("error" in result).toBe(true);
|
|
546
|
+
if ("error" in result) {
|
|
547
|
+
expect(result.error.status).toBe(401);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("env unset + non-loopback simulated via X-Forwarded-For → still 401 without bearer", async () => {
|
|
552
|
+
// Doc note: vault has NO implicit loopback trust at the auth layer.
|
|
553
|
+
// The X-Forwarded-For shape (set by hub / Cloudflare Tunnel / etc.)
|
|
554
|
+
// doesn't affect the auth gate; tokens are required regardless of
|
|
555
|
+
// socket origin. The `bind.ts` 127.0.0.1 default is a socket-level
|
|
556
|
+
// listen-restriction, not a trust-asymmetric auth bypass.
|
|
557
|
+
delete process.env.VAULT_AUTH_TOKEN;
|
|
558
|
+
seedVault("journal");
|
|
559
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
560
|
+
const journalStore = getVaultStore("journal");
|
|
561
|
+
|
|
562
|
+
const remote = new Request("https://vault.test/x", {
|
|
563
|
+
headers: { "X-Forwarded-For": "203.0.113.7" },
|
|
564
|
+
});
|
|
565
|
+
const result = await authenticateVaultRequest(remote, journalConfig, journalStore.db);
|
|
566
|
+
expect("error" in result).toBe(true);
|
|
567
|
+
if ("error" in result) {
|
|
568
|
+
expect(result.error.status).toBe(401);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("env set with whitespace-only value → treated as unset", async () => {
|
|
573
|
+
process.env.VAULT_AUTH_TOKEN = " ";
|
|
574
|
+
seedVault("journal");
|
|
575
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
576
|
+
const journalStore = getVaultStore("journal");
|
|
577
|
+
|
|
578
|
+
// An empty/whitespace VAULT_AUTH_TOKEN must NOT allow any bearer to
|
|
579
|
+
// pass — the operator either commits to bearer auth or doesn't.
|
|
580
|
+
const result = await authenticateVaultRequest(
|
|
581
|
+
bearer(""),
|
|
582
|
+
journalConfig,
|
|
583
|
+
journalStore.db,
|
|
584
|
+
);
|
|
585
|
+
expect("error" in result).toBe(true);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("env set + matching bearer also works on the global auth surface", async () => {
|
|
589
|
+
// /vaults metadata listing + /health vault names go through
|
|
590
|
+
// authenticateGlobalRequest. The server-wide bearer must work there
|
|
591
|
+
// too — otherwise hub couldn't enumerate vaults using the operator
|
|
592
|
+
// channel.
|
|
593
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
594
|
+
seedVault("journal");
|
|
595
|
+
|
|
596
|
+
const result = await authenticateGlobalRequest(bearer(TOKEN));
|
|
597
|
+
expect("error" in result).toBe(false);
|
|
598
|
+
if (!("error" in result)) {
|
|
599
|
+
expect(result.permission).toBe("full");
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("env set + wrong bearer on global auth surface → 401", async () => {
|
|
604
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
605
|
+
seedVault("journal");
|
|
606
|
+
|
|
607
|
+
const result = await authenticateGlobalRequest(bearer("wrong-token"));
|
|
608
|
+
expect("error" in result).toBe(true);
|
|
609
|
+
if ("error" in result) {
|
|
610
|
+
expect(result.error.status).toBe(401);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("near-miss bearer (one-char difference, same length) → 401 (constant-time compare)", async () => {
|
|
615
|
+
// Defensive: the server-wide compare uses crypto.timingSafeEqual so
|
|
616
|
+
// a one-char-off bearer that matches length-wise still rejects.
|
|
617
|
+
// We can't measure timing in a unit test, but we can pin the
|
|
618
|
+
// correctness side: a same-length near-miss must still reject.
|
|
619
|
+
process.env.VAULT_AUTH_TOKEN = TOKEN;
|
|
620
|
+
seedVault("journal");
|
|
621
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
622
|
+
const journalStore = getVaultStore("journal");
|
|
623
|
+
|
|
624
|
+
const nearMiss = TOKEN.slice(0, -1) + "x";
|
|
625
|
+
expect(nearMiss).not.toBe(TOKEN);
|
|
626
|
+
expect(nearMiss.length).toBe(TOKEN.length);
|
|
627
|
+
|
|
628
|
+
const result = await authenticateVaultRequest(
|
|
629
|
+
bearer(nearMiss),
|
|
630
|
+
journalConfig,
|
|
631
|
+
journalStore.db,
|
|
632
|
+
);
|
|
633
|
+
expect("error" in result).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
});
|