@openparachute/vault 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to Parachute Vault are documented here.
4
4
 
5
5
  This project loosely follows [Keep a Changelog](https://keepachangelog.com) and [Semantic Versioning](https://semver.org).
6
6
 
7
+ ## [0.2.1] — 2026-04-17
8
+
9
+ ### Fixed
10
+
11
+ - OAuth discovery now works against Claude Code's MCP SDK (and any other strict RFC 9728 client): 401 responses from the MCP endpoint carry a `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at the scoped or unscoped protected-resource metadata document, matching the URL the client actually hit. Previously, clients with no pointer fell back to probing the root `/.well-known/oauth-protected-resource`, got `resource: <base>/mcp`, and rejected any connection to `/vaults/<name>/mcp` as a resource mismatch.
12
+
7
13
  ## [0.2.0] — 2026-04-17
8
14
 
9
15
  First tagged public release. Ships the auth, backup, and onboarding surface the project needs for first-wave users.
@@ -77,4 +83,5 @@ First tagged public release. Ships the auth, backup, and onboarding surface the
77
83
  - **`core/src/test-preload.ts`** isolates `PARACHUTE_HOME` for tests so `bun test` never touches a user's real `~/.parachute/`.
78
84
  - Test suite at release cut: **538 passing / 0 failing / 3 skipped** across 22 files (541 tests total).
79
85
 
86
+ [0.2.1]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.1
80
87
  [0.2.0]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/oauth.ts CHANGED
@@ -44,7 +44,15 @@ export interface AuthorizePostOptions {
44
44
  // Helpers
45
45
  // ---------------------------------------------------------------------------
46
46
 
47
- function getBaseUrl(req: Request): string {
47
+ /**
48
+ * Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
49
+ * Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
50
+ * the right external origin in discovery documents (RFC 8414, RFC 9728).
51
+ *
52
+ * Exported so the router can build `WWW-Authenticate` challenge headers that
53
+ * point at the same origin as the `/.well-known/*` metadata documents.
54
+ */
55
+ export function getBaseUrl(req: Request): string {
48
56
  const forwardedHost = req.headers.get("x-forwarded-host");
49
57
  const forwardedProto = req.headers.get("x-forwarded-proto");
50
58
  if (forwardedHost) {
@@ -345,3 +345,134 @@ describe("single-vault auto-default", () => {
345
345
  expect(res.status).toBe(401); // reached per-vault auth
346
346
  });
347
347
  });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // RFC 9728 WWW-Authenticate challenge on MCP 401.
351
+ //
352
+ // Claude Code's MCP SDK (and any other strict RFC 9728 client) requires the
353
+ // server to emit `WWW-Authenticate: Bearer resource_metadata="..."` on 401
354
+ // so the client knows which protected-resource metadata document applies to
355
+ // the endpoint it just hit. Without it, clients fall back to probing the
356
+ // root `/.well-known/oauth-protected-resource`, get `resource: <base>/mcp`,
357
+ // and reject any connection to `/vaults/<name>/mcp` as a resource mismatch.
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
361
+ test("unscoped /mcp 401 carries the root protected-resource pointer", async () => {
362
+ createVault("journal");
363
+ const req = new Request("http://localhost:1940/mcp");
364
+ const res = await route(req, "/mcp");
365
+ expect(res.status).toBe(401);
366
+ const header = res.headers.get("WWW-Authenticate");
367
+ expect(header).toBe(
368
+ 'Bearer resource_metadata="http://localhost:1940/.well-known/oauth-protected-resource"',
369
+ );
370
+ });
371
+
372
+ test("scoped /vaults/{name}/mcp 401 carries the vault-scoped pointer", async () => {
373
+ createVault("journal");
374
+ const req = new Request("http://localhost:1940/vaults/journal/mcp");
375
+ const res = await route(req, "/vaults/journal/mcp");
376
+ expect(res.status).toBe(401);
377
+ const header = res.headers.get("WWW-Authenticate");
378
+ expect(header).toBe(
379
+ 'Bearer resource_metadata="http://localhost:1940/vaults/journal/.well-known/oauth-protected-resource"',
380
+ );
381
+ });
382
+
383
+ test("challenge points at the same PRM document the server actually serves", async () => {
384
+ // Belt-and-braces: whatever we advertise in the header MUST line up with
385
+ // what `/.well-known/oauth-protected-resource` actually returns. If these
386
+ // drift, a conforming client will chase the pointer, fetch the PRM, then
387
+ // reject on resource mismatch anyway. Test both directions.
388
+ createVault("journal");
389
+
390
+ // Scoped: header points at /vaults/journal/.well-known/...
391
+ const scopedReq = new Request("http://localhost:1940/vaults/journal/mcp");
392
+ const scopedRes = await route(scopedReq, "/vaults/journal/mcp");
393
+ const scopedHeader = scopedRes.headers.get("WWW-Authenticate")!;
394
+ const scopedPrmUrl = scopedHeader.match(/resource_metadata="([^"]+)"/)![1];
395
+ // Fetch that PRM. Bypass the full URL by extracting the path.
396
+ const prmPath = new URL(scopedPrmUrl).pathname;
397
+ const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
398
+ expect(prmRes.status).toBe(200);
399
+ const prm = (await prmRes.json()) as { resource: string };
400
+ expect(prm.resource).toBe("http://localhost:1940/vaults/journal/mcp");
401
+
402
+ // Unscoped: header points at root /.well-known/...
403
+ const unscopedReq = new Request("http://localhost:1940/mcp");
404
+ const unscopedRes = await route(unscopedReq, "/mcp");
405
+ const unscopedHeader = unscopedRes.headers.get("WWW-Authenticate")!;
406
+ const unscopedPrmUrl = unscopedHeader.match(/resource_metadata="([^"]+)"/)![1];
407
+ const unscopedPrmPath = new URL(unscopedPrmUrl).pathname;
408
+ const unscopedPrmRes = await route(
409
+ new Request(`http://localhost:1940${unscopedPrmPath}`),
410
+ unscopedPrmPath,
411
+ );
412
+ expect(unscopedPrmRes.status).toBe(200);
413
+ const unscopedPrm = (await unscopedPrmRes.json()) as { resource: string };
414
+ expect(unscopedPrm.resource).toBe("http://localhost:1940/mcp");
415
+ });
416
+
417
+ test("MCP 401 with invalid token still carries the challenge", async () => {
418
+ // The no-token case is one 401 code path (extractApiKey returns null);
419
+ // the invalid-token case is another (extractApiKey returns a string but
420
+ // resolveToken / validateKey all fail). Both must emit the header.
421
+ createVault("journal");
422
+ const req = new Request("http://localhost:1940/vaults/journal/mcp", {
423
+ headers: { Authorization: "Bearer pvt_not-a-real-token" },
424
+ });
425
+ const res = await route(req, "/vaults/journal/mcp");
426
+ expect(res.status).toBe(401);
427
+ expect(res.headers.get("WWW-Authenticate")).toBe(
428
+ 'Bearer resource_metadata="http://localhost:1940/vaults/journal/.well-known/oauth-protected-resource"',
429
+ );
430
+ });
431
+
432
+ test("non-MCP 401s do NOT carry the challenge (spec is MCP-only)", async () => {
433
+ // The RFC 9728 challenge header is specific to the MCP resource; plain
434
+ // REST endpoints are not OAuth resources in the same sense. A spurious
435
+ // challenge here could confuse non-MCP clients and makes the /api
436
+ // surface look OAuth-gated when it is not.
437
+ createVault("journal");
438
+
439
+ // /api/notes (unscoped) — 401, no challenge.
440
+ const unscopedApi = await route(new Request("http://localhost:1940/api/notes"), "/api/notes");
441
+ expect(unscopedApi.status).toBe(401);
442
+ expect(unscopedApi.headers.get("WWW-Authenticate")).toBeNull();
443
+
444
+ // /vaults/journal/api/notes (scoped) — 401, no challenge. This is the
445
+ // code path that shares the auth check with the scoped MCP branch, so
446
+ // if we leak the header here the isScopedMcp gate has regressed.
447
+ const scopedApi = await route(
448
+ new Request("http://localhost:1940/vaults/journal/api/notes"),
449
+ "/vaults/journal/api/notes",
450
+ );
451
+ expect(scopedApi.status).toBe(401);
452
+ expect(scopedApi.headers.get("WWW-Authenticate")).toBeNull();
453
+
454
+ // /vaults (authenticated listing) — 401, no challenge.
455
+ const vaultsList = await route(new Request("http://localhost:1940/vaults"), "/vaults");
456
+ expect(vaultsList.status).toBe(401);
457
+ expect(vaultsList.headers.get("WWW-Authenticate")).toBeNull();
458
+ });
459
+
460
+ test("x-forwarded-host and x-forwarded-proto shape the challenge URL", async () => {
461
+ // Remote deployments behind Cloudflare Tunnel / Tailscale Funnel / any
462
+ // reverse proxy need the challenge URL to match the external origin,
463
+ // not the 127.0.0.1:1940 the server actually binds. Parallels how the
464
+ // /.well-known/* endpoints already honor these headers.
465
+ createVault("journal");
466
+ const req = new Request("http://127.0.0.1:1940/vaults/journal/mcp", {
467
+ headers: {
468
+ "x-forwarded-host": "vault.example.com",
469
+ "x-forwarded-proto": "https",
470
+ },
471
+ });
472
+ const res = await route(req, "/vaults/journal/mcp");
473
+ expect(res.status).toBe(401);
474
+ expect(res.headers.get("WWW-Authenticate")).toBe(
475
+ 'Bearer resource_metadata="https://vault.example.com/vaults/journal/.well-known/oauth-protected-resource"',
476
+ );
477
+ });
478
+ });
package/src/routing.ts CHANGED
@@ -49,8 +49,49 @@ import {
49
49
  handleAuthorizeGet,
50
50
  handleAuthorizePost,
51
51
  handleToken,
52
+ getBaseUrl,
52
53
  } from "./oauth.ts";
53
54
 
55
+ /**
56
+ * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
57
+ * header pointing at the matching protected-resource metadata document.
58
+ *
59
+ * An MCP-capable OAuth client that receives a plain 401 has no structured way
60
+ * to discover which authorization server to use, and SDKs that follow RFC 9728
61
+ * (including Claude Code's) default to probing the *root* `/.well-known/oauth-
62
+ * protected-resource`. That document advertises `resource: <base>/mcp` — which
63
+ * then fails the SDK's strict resource-URL match when the client is actually
64
+ * connecting to `/vaults/{name}/mcp`. The `WWW-Authenticate` header tells the
65
+ * client exactly which metadata document applies to the endpoint it just hit,
66
+ * closing the mismatch.
67
+ *
68
+ * Scoped calls pass `vaultName`; unscoped omits it. Other 401-emitting
69
+ * endpoints (`/api/*`, `/vaults`, `/health` when authenticated) are not MCP
70
+ * resources and intentionally do not carry this header.
71
+ */
72
+ function mcpWwwAuthenticate(req: Request, vaultName?: string): string {
73
+ const base = getBaseUrl(req);
74
+ const prefix = vaultName ? `/vaults/${vaultName}` : "";
75
+ return `Bearer resource_metadata="${base}${prefix}/.well-known/oauth-protected-resource"`;
76
+ }
77
+
78
+ /**
79
+ * Clone a 401 Response and attach the `WWW-Authenticate` challenge header.
80
+ * The auth module returns a fully-baked `Response`, and headers on a consumed
81
+ * `Response` can't be mutated in place; cloning is the cheap path.
82
+ */
83
+ async function withMcpChallenge(
84
+ res: Response,
85
+ req: Request,
86
+ vaultName?: string,
87
+ ): Promise<Response> {
88
+ if (res.status !== 401) return res;
89
+ const body = await res.text();
90
+ const headers = new Headers(res.headers);
91
+ headers.set("WWW-Authenticate", mcpWwwAuthenticate(req, vaultName));
92
+ return new Response(body, { status: 401, headers });
93
+ }
94
+
54
95
  /**
55
96
  * Check if a /view request has a valid API key (header or ?key= query param).
56
97
  * Returns true if authenticated, false if not. Never rejects — unauthenticated
@@ -134,7 +175,7 @@ export async function route(
134
175
  // Unified MCP (all vaults, global auth)
135
176
  if (path === "/mcp" || path.startsWith("/mcp/")) {
136
177
  const auth = authenticateGlobalRequest(req);
137
- if ("error" in auth) return auth.error;
178
+ if ("error" in auth) return withMcpChallenge(auth.error, req);
138
179
  return handleUnifiedMcp(req, auth);
139
180
  }
140
181
 
@@ -308,13 +349,20 @@ export async function route(
308
349
  return handleAuthorizationServer(req, vaultName);
309
350
  }
310
351
 
311
- // Auth: per-vault key OR global key
352
+ // Auth: per-vault key OR global key.
353
+ // The auth check is shared between the scoped MCP branch and the scoped
354
+ // /api/* branches, so we can't unconditionally attach the MCP-only
355
+ // WWW-Authenticate challenge here — we pass the challenge back only when
356
+ // the failing request was actually targeting /vaults/{name}/mcp.
312
357
  const store = getVaultStore(vaultName);
313
358
  const auth = authenticateVaultRequest(req, vaultConfig, store.db);
314
- if ("error" in auth) return auth.error;
359
+ const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
360
+ if ("error" in auth) {
361
+ return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
362
+ }
315
363
 
316
364
  // Per-vault scoped MCP
317
- if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
365
+ if (isScopedMcp) {
318
366
  return handleScopedMcp(req, vaultName, auth);
319
367
  }
320
368