@openparachute/vault 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/routing.test.ts +183 -0
- package/src/routing.ts +37 -1
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.3] — 2026-04-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **OAuth discovery endpoints now served at RFC-compliant path-insertion URLs (`/.well-known/oauth-authorization-server/{path}`) in addition to the existing path-append form.** Restores Claude Code's MCP OAuth SDK compatibility, which follows RFC 8414 §3.1 and RFC 9728 §3 strictly and probes only the path-insertion shape. Before 0.2.3, the SDK's AS-metadata fetch 404'd, leaving it without a `registration_endpoint` and cascading into a 404 on the `/register` fallback. Both scoped forms now work: `/.well-known/oauth-authorization-server/vaults/<name>` and the longer `/.well-known/oauth-authorization-server/vaults/<name>/mcp`; same shapes on `/.well-known/oauth-protected-resource/...`. Path-append routes (`/vaults/<name>/.well-known/<type>`) are unchanged so lax clients keep working.
|
|
12
|
+
|
|
7
13
|
## [0.2.2] — 2026-04-17
|
|
8
14
|
|
|
9
15
|
### Fixed
|
|
@@ -93,6 +99,7 @@ First tagged public release. Ships the auth, backup, and onboarding surface the
|
|
|
93
99
|
- **`core/src/test-preload.ts`** isolates `PARACHUTE_HOME` for tests so `bun test` never touches a user's real `~/.parachute/`.
|
|
94
100
|
- Test suite at release cut: **538 passing / 0 failing / 3 skipped** across 22 files (541 tests total).
|
|
95
101
|
|
|
102
|
+
[0.2.3]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.3
|
|
96
103
|
[0.2.2]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.2
|
|
97
104
|
[0.2.1]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.1
|
|
98
105
|
[0.2.0]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.0
|
package/package.json
CHANGED
package/src/routing.test.ts
CHANGED
|
@@ -476,3 +476,186 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
476
476
|
);
|
|
477
477
|
});
|
|
478
478
|
});
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// RFC 8414 §3.1 / RFC 9728 §3 path-insertion discovery.
|
|
482
|
+
//
|
|
483
|
+
// For a resource at `/vaults/<name>/mcp`, the spec-mandated metadata URLs are
|
|
484
|
+
// /.well-known/oauth-authorization-server/vaults/<name>[/mcp]
|
|
485
|
+
// /.well-known/oauth-protected-resource/vaults/<name>[/mcp]
|
|
486
|
+
// rather than the path-append form
|
|
487
|
+
// /vaults/<name>/.well-known/<type>
|
|
488
|
+
// that PR #111 also ships. Strict clients (including Claude Code's MCP OAuth
|
|
489
|
+
// SDK) probe only the path-insertion form; lax clients try path-append. We
|
|
490
|
+
// serve both so any conformant probe hits a live endpoint.
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
describe("path-insertion OAuth discovery (RFC 8414 §3.1 / RFC 9728 §3)", () => {
|
|
494
|
+
test("/.well-known/oauth-authorization-server/vaults/<name> returns vault-scoped AS metadata", async () => {
|
|
495
|
+
createVault("journal");
|
|
496
|
+
const path = "/.well-known/oauth-authorization-server/vaults/journal";
|
|
497
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
498
|
+
expect(res.status).toBe(200);
|
|
499
|
+
const body = (await res.json()) as {
|
|
500
|
+
issuer: string;
|
|
501
|
+
authorization_endpoint: string;
|
|
502
|
+
token_endpoint: string;
|
|
503
|
+
registration_endpoint: string;
|
|
504
|
+
};
|
|
505
|
+
// All four endpoints must be vault-scoped — otherwise Claude Code's
|
|
506
|
+
// registration_endpoint falls back to root `/register` and cascades 404.
|
|
507
|
+
expect(body.issuer).toBe("http://localhost:1940/vaults/journal");
|
|
508
|
+
expect(body.authorization_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/authorize");
|
|
509
|
+
expect(body.token_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/token");
|
|
510
|
+
expect(body.registration_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/register");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("/.well-known/oauth-authorization-server/vaults/<name>/mcp (longer form) also returns AS metadata", async () => {
|
|
514
|
+
// Aaron's log shows Claude Code probes this longer form too; cheap to
|
|
515
|
+
// support since it resolves to the same AS for the same vault.
|
|
516
|
+
createVault("journal");
|
|
517
|
+
const path = "/.well-known/oauth-authorization-server/vaults/journal/mcp";
|
|
518
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
519
|
+
expect(res.status).toBe(200);
|
|
520
|
+
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
521
|
+
expect(body.issuer).toBe("http://localhost:1940/vaults/journal");
|
|
522
|
+
expect(body.registration_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/register");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("/.well-known/oauth-protected-resource/vaults/<name> returns vault-scoped PRM", async () => {
|
|
526
|
+
createVault("journal");
|
|
527
|
+
const path = "/.well-known/oauth-protected-resource/vaults/journal";
|
|
528
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
529
|
+
expect(res.status).toBe(200);
|
|
530
|
+
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
531
|
+
expect(body.resource).toBe("http://localhost:1940/vaults/journal/mcp");
|
|
532
|
+
expect(body.authorization_servers).toEqual(["http://localhost:1940/vaults/journal"]);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("/.well-known/oauth-protected-resource/vaults/<name>/mcp (longer form) also returns PRM", async () => {
|
|
536
|
+
createVault("journal");
|
|
537
|
+
const path = "/.well-known/oauth-protected-resource/vaults/journal/mcp";
|
|
538
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
539
|
+
expect(res.status).toBe(200);
|
|
540
|
+
const body = (await res.json()) as { resource: string };
|
|
541
|
+
expect(body.resource).toBe("http://localhost:1940/vaults/journal/mcp");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("path-insertion and path-append forms return identical metadata", async () => {
|
|
545
|
+
// The coherence guarantee: a client that follows either spec shape MUST
|
|
546
|
+
// land on the same AS config. If these drift, a mixed-toolchain deploy
|
|
547
|
+
// (CLI using one form, daemon using the other) would mint tokens
|
|
548
|
+
// against inconsistent endpoints.
|
|
549
|
+
createVault("journal");
|
|
550
|
+
|
|
551
|
+
// AS metadata
|
|
552
|
+
const insertAsPath = "/.well-known/oauth-authorization-server/vaults/journal";
|
|
553
|
+
const appendAsPath = "/vaults/journal/.well-known/oauth-authorization-server";
|
|
554
|
+
const insertAsRes = await route(new Request(`http://localhost:1940${insertAsPath}`), insertAsPath);
|
|
555
|
+
const appendAsRes = await route(new Request(`http://localhost:1940${appendAsPath}`), appendAsPath);
|
|
556
|
+
expect(await insertAsRes.json()).toEqual(await appendAsRes.json());
|
|
557
|
+
|
|
558
|
+
// PRM
|
|
559
|
+
const insertPrmPath = "/.well-known/oauth-protected-resource/vaults/journal";
|
|
560
|
+
const appendPrmPath = "/vaults/journal/.well-known/oauth-protected-resource";
|
|
561
|
+
const insertPrmRes = await route(new Request(`http://localhost:1940${insertPrmPath}`), insertPrmPath);
|
|
562
|
+
const appendPrmRes = await route(new Request(`http://localhost:1940${appendPrmPath}`), appendPrmPath);
|
|
563
|
+
expect(await insertPrmRes.json()).toEqual(await appendPrmRes.json());
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("unknown vault in path-insertion URL returns 404, not boilerplate metadata", async () => {
|
|
567
|
+
// Don't leak metadata for phantom vaults. The equivalent path-append
|
|
568
|
+
// route also 404s when the vault doesn't exist (`readVaultConfig` miss
|
|
569
|
+
// at the vault-scoped routes branch); path-insertion must match.
|
|
570
|
+
createVault("journal");
|
|
571
|
+
for (const path of [
|
|
572
|
+
"/.well-known/oauth-authorization-server/vaults/nonexistent",
|
|
573
|
+
"/.well-known/oauth-authorization-server/vaults/nonexistent/mcp",
|
|
574
|
+
"/.well-known/oauth-protected-resource/vaults/nonexistent",
|
|
575
|
+
"/.well-known/oauth-protected-resource/vaults/nonexistent/mcp",
|
|
576
|
+
]) {
|
|
577
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
578
|
+
expect(res.status).toBe(404);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("x-forwarded-* headers propagate into the generated metadata URLs", async () => {
|
|
583
|
+
// Same contract as the WWW-Authenticate challenge and the root/append
|
|
584
|
+
// discovery endpoints: metadata must match the public-facing origin so
|
|
585
|
+
// a Cloudflare Tunnel / Tailscale Funnel deployment doesn't advertise
|
|
586
|
+
// internal localhost:1940 URLs.
|
|
587
|
+
createVault("journal");
|
|
588
|
+
const path = "/.well-known/oauth-authorization-server/vaults/journal";
|
|
589
|
+
const res = await route(
|
|
590
|
+
new Request(`http://127.0.0.1:1940${path}`, {
|
|
591
|
+
headers: {
|
|
592
|
+
"x-forwarded-host": "vault.example.com",
|
|
593
|
+
"x-forwarded-proto": "https",
|
|
594
|
+
},
|
|
595
|
+
}),
|
|
596
|
+
path,
|
|
597
|
+
);
|
|
598
|
+
expect(res.status).toBe(200);
|
|
599
|
+
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
600
|
+
expect(body.issuer).toBe("https://vault.example.com/vaults/journal");
|
|
601
|
+
expect(body.registration_endpoint).toBe(
|
|
602
|
+
"https://vault.example.com/vaults/journal/oauth/register",
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("end-to-end flow: WWW-Authenticate → PRM → AS metadata → registration_endpoint is live", async () => {
|
|
607
|
+
// The actual Claude-Code bug: on 401, follow the challenge to the PRM,
|
|
608
|
+
// then follow PRM.authorization_servers[0] to the AS metadata (via
|
|
609
|
+
// path-insertion), then hit the `registration_endpoint`. Every hop
|
|
610
|
+
// must resolve — before the fix, the AS-metadata-via-path-insertion
|
|
611
|
+
// step 404'd and the SDK fell back to `/register` which also 404'd.
|
|
612
|
+
createVault("journal");
|
|
613
|
+
|
|
614
|
+
// Step 1: unauthenticated MCP → 401 + WWW-Authenticate.
|
|
615
|
+
const mcpRes = await route(
|
|
616
|
+
new Request("http://localhost:1940/vaults/journal/mcp"),
|
|
617
|
+
"/vaults/journal/mcp",
|
|
618
|
+
);
|
|
619
|
+
expect(mcpRes.status).toBe(401);
|
|
620
|
+
const challenge = mcpRes.headers.get("WWW-Authenticate")!;
|
|
621
|
+
const prmUrl = challenge.match(/resource_metadata="([^"]+)"/)![1];
|
|
622
|
+
|
|
623
|
+
// Step 2: fetch PRM. The challenge points at the path-append form, but
|
|
624
|
+
// a strict client might also try path-insertion — both must work.
|
|
625
|
+
// Follow the advertised URL (path-append in this case) and note the
|
|
626
|
+
// authorization_servers pointer.
|
|
627
|
+
const prmPath = new URL(prmUrl).pathname;
|
|
628
|
+
const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
|
|
629
|
+
expect(prmRes.status).toBe(200);
|
|
630
|
+
const prm = (await prmRes.json()) as { authorization_servers: string[] };
|
|
631
|
+
const asBase = prm.authorization_servers[0]; // "http://localhost:1940/vaults/journal"
|
|
632
|
+
|
|
633
|
+
// Step 3: strict-client path-insertion probe for AS metadata.
|
|
634
|
+
const asBasePath = new URL(asBase).pathname; // "/vaults/journal"
|
|
635
|
+
const asInsertPath = `/.well-known/oauth-authorization-server${asBasePath}`;
|
|
636
|
+
const asRes = await route(
|
|
637
|
+
new Request(`http://localhost:1940${asInsertPath}`),
|
|
638
|
+
asInsertPath,
|
|
639
|
+
);
|
|
640
|
+
// This was the 404 before the fix — the reason Claude Code's SDK gave
|
|
641
|
+
// up and cascade-404'd on `/register`.
|
|
642
|
+
expect(asRes.status).toBe(200);
|
|
643
|
+
const asMeta = (await asRes.json()) as { registration_endpoint: string };
|
|
644
|
+
|
|
645
|
+
// Step 4: the advertised registration_endpoint must be live (POST-only).
|
|
646
|
+
const regPath = new URL(asMeta.registration_endpoint).pathname;
|
|
647
|
+
const regRes = await route(
|
|
648
|
+
new Request(`http://localhost:1940${regPath}`, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "Content-Type": "application/json" },
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
client_name: "Test",
|
|
653
|
+
redirect_uris: ["https://example.com/cb"],
|
|
654
|
+
}),
|
|
655
|
+
}),
|
|
656
|
+
regPath,
|
|
657
|
+
);
|
|
658
|
+
// Successful DCR is 201; anything but 404 proves the endpoint is wired.
|
|
659
|
+
expect(regRes.status).toBe(201);
|
|
660
|
+
});
|
|
661
|
+
});
|
package/src/routing.ts
CHANGED
|
@@ -115,7 +115,43 @@ export async function route(
|
|
|
115
115
|
path: string,
|
|
116
116
|
clientIp?: string,
|
|
117
117
|
): Promise<Response> {
|
|
118
|
-
// OAuth discovery endpoints (no auth required)
|
|
118
|
+
// OAuth discovery endpoints (no auth required).
|
|
119
|
+
//
|
|
120
|
+
// RFC 8414 §3.1 and RFC 9728 §3 specify the discovery URL shape when an
|
|
121
|
+
// authorization server (or protected resource) has a path component `/p`:
|
|
122
|
+
//
|
|
123
|
+
// Path-insertion (spec-mandated):
|
|
124
|
+
// <host>/.well-known/<metadata-type>/p
|
|
125
|
+
// Path-append (widespread in the wild, shipped in PR #111):
|
|
126
|
+
// <host>/p/.well-known/<metadata-type>
|
|
127
|
+
//
|
|
128
|
+
// Strict clients — including Claude Code's MCP OAuth SDK — probe only the
|
|
129
|
+
// path-insertion form. Lax clients try path-append. We serve both so any
|
|
130
|
+
// conformant probe hits a live endpoint. Unscoped root forms
|
|
131
|
+
// (`/.well-known/oauth-*`) are the third accepted shape, and the
|
|
132
|
+
// path-append branch for scoped discovery lives further down alongside the
|
|
133
|
+
// other `/vaults/{name}/*` routing.
|
|
134
|
+
const protectedResourceInsert = path.match(
|
|
135
|
+
/^\/\.well-known\/oauth-protected-resource\/vaults\/([^/]+)(?:\/mcp)?$/,
|
|
136
|
+
);
|
|
137
|
+
if (protectedResourceInsert) {
|
|
138
|
+
const vaultName = protectedResourceInsert[1];
|
|
139
|
+
if (!readVaultConfig(vaultName)) {
|
|
140
|
+
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
141
|
+
}
|
|
142
|
+
return handleProtectedResource(req, `/vaults/${vaultName}/mcp`, `/vaults/${vaultName}`);
|
|
143
|
+
}
|
|
144
|
+
const authServerInsert = path.match(
|
|
145
|
+
/^\/\.well-known\/oauth-authorization-server\/vaults\/([^/]+)(?:\/mcp)?$/,
|
|
146
|
+
);
|
|
147
|
+
if (authServerInsert) {
|
|
148
|
+
const vaultName = authServerInsert[1];
|
|
149
|
+
if (!readVaultConfig(vaultName)) {
|
|
150
|
+
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
151
|
+
}
|
|
152
|
+
return handleAuthorizationServer(req, vaultName);
|
|
153
|
+
}
|
|
154
|
+
|
|
119
155
|
if (path === "/.well-known/oauth-protected-resource") {
|
|
120
156
|
return handleProtectedResource(req);
|
|
121
157
|
}
|