@openparachute/vault 0.4.8-rc.6 → 0.4.8-rc.9
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/.parachute/module.json +1 -0
- package/README.md +34 -31
- package/core/src/schema.ts +8 -3
- package/package.json +7 -3
- package/src/auth.test.ts +5 -112
- package/src/backup.ts +17 -3
- package/src/cli.ts +38 -18
- package/src/export-watch.test.ts +21 -0
- package/src/oauth-discovery.ts +95 -0
- package/src/owner-auth.ts +22 -149
- package/src/routing.test.ts +98 -97
- package/src/routing.ts +29 -43
- package/src/server.ts +1 -12
- package/src/vault-name.ts +3 -2
- package/web/ui/dist/assets/index-BOa-JJtV.css +1 -0
- package/web/ui/dist/assets/index-BzA5LgE3.js +60 -0
- package/web/ui/dist/index.html +14 -0
- package/web/ui/tsconfig.json +21 -0
- package/src/oauth.test.ts +0 -2156
- package/src/oauth.ts +0 -973
package/src/owner-auth.ts
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Owner
|
|
2
|
+
* Owner-password storage + verification.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* **Vestigial after vault#366 (workstream E of the UX audit, 2026-05-25).**
|
|
5
|
+
* The owner password used to authenticate the vault's standalone OAuth
|
|
6
|
+
* consent page (the one rendered by the now-deleted `src/oauth.ts`). With
|
|
7
|
+
* hub required and consent moved to the hub, the password no longer
|
|
8
|
+
* protects anything inside vault. The module is kept because:
|
|
7
9
|
*
|
|
8
|
-
*
|
|
10
|
+
* 1. Hub's `expose public` preflight reads `owner_password_hash` /
|
|
11
|
+
* `totp_secret` from vault's `config.yaml` to score auth posture
|
|
12
|
+
* (`parachute-hub/src/vault/auth-status.ts`). Removing the YAML
|
|
13
|
+
* surface in lockstep would turn every install's preflight
|
|
14
|
+
* score into "wide-open" until hub ships its own posture check.
|
|
15
|
+
* 2. The CLI `set-password` / `2fa enroll` commands keep working for
|
|
16
|
+
* operators on the legacy posture mid-migration.
|
|
9
17
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
18
|
+
* Retirement is tracked as a follow-up; this file should go away once
|
|
19
|
+
* the hub-side preflight is updated to score hub credentials instead of
|
|
20
|
+
* vault credentials.
|
|
21
|
+
*
|
|
22
|
+
* The per-IP `RateLimiter` (formerly in this file) was deleted alongside
|
|
23
|
+
* the consent page — there's no traffic to limit on a route that no
|
|
24
|
+
* longer exists.
|
|
25
|
+
*
|
|
26
|
+
* Password hashing uses Bun.password (bcrypt, cost 12 by default).
|
|
12
27
|
*/
|
|
13
28
|
|
|
14
29
|
import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
|
|
@@ -71,145 +86,3 @@ export async function verifyOwnerPassword(password: string, hash: string): Promi
|
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Rate limiting
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
interface RateLimitEntry {
|
|
79
|
-
failures: number;
|
|
80
|
-
firstFailureAt: number;
|
|
81
|
-
lockedUntil: number | null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Per-IP rate limiter for consent-page attempts.
|
|
86
|
-
*
|
|
87
|
-
* Policy:
|
|
88
|
-
* - Up to MAX_FAILURES failed attempts within WINDOW_MS → lockout
|
|
89
|
-
* - Lockout lasts LOCKOUT_MS
|
|
90
|
-
* - A successful attempt clears the IP's counter
|
|
91
|
-
* - Hard cap on entry count — when full, the oldest insertion is evicted
|
|
92
|
-
* before a new one is recorded. Prevents memory exhaustion via IP /
|
|
93
|
-
* client_id enumeration (#93).
|
|
94
|
-
*/
|
|
95
|
-
export class RateLimiter {
|
|
96
|
-
private entries = new Map<string, RateLimitEntry>();
|
|
97
|
-
|
|
98
|
-
constructor(
|
|
99
|
-
private readonly maxFailures = 10,
|
|
100
|
-
private readonly windowMs = 60_000,
|
|
101
|
-
private readonly lockoutMs = 15 * 60_000,
|
|
102
|
-
private readonly maxEntries = 10_000,
|
|
103
|
-
) {}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Check whether an IP is currently allowed to attempt auth.
|
|
107
|
-
* Returns `{ allowed: false, retryAfterSec }` if locked out.
|
|
108
|
-
*/
|
|
109
|
-
check(ip: string): { allowed: true } | { allowed: false; retryAfterSec: number } {
|
|
110
|
-
const entry = this.entries.get(ip);
|
|
111
|
-
if (!entry) return { allowed: true };
|
|
112
|
-
|
|
113
|
-
const now = Date.now();
|
|
114
|
-
if (entry.lockedUntil && entry.lockedUntil > now) {
|
|
115
|
-
return { allowed: false, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Expired lockout or old window — reset and allow
|
|
119
|
-
if (entry.lockedUntil && entry.lockedUntil <= now) {
|
|
120
|
-
this.entries.delete(ip);
|
|
121
|
-
return { allowed: true };
|
|
122
|
-
}
|
|
123
|
-
if (now - entry.firstFailureAt > this.windowMs) {
|
|
124
|
-
this.entries.delete(ip);
|
|
125
|
-
return { allowed: true };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { allowed: true };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Record a failed attempt. Triggers lockout if threshold reached. */
|
|
132
|
-
recordFailure(ip: string): void {
|
|
133
|
-
const now = Date.now();
|
|
134
|
-
const entry = this.entries.get(ip);
|
|
135
|
-
|
|
136
|
-
if (!entry || now - entry.firstFailureAt > this.windowMs) {
|
|
137
|
-
this.evictIfFull();
|
|
138
|
-
this.entries.set(ip, {
|
|
139
|
-
failures: 1,
|
|
140
|
-
firstFailureAt: now,
|
|
141
|
-
lockedUntil: null,
|
|
142
|
-
});
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
entry.failures += 1;
|
|
147
|
-
if (entry.failures >= this.maxFailures) {
|
|
148
|
-
entry.lockedUntil = now + this.lockoutMs;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Record a successful attempt. Clears the IP's counter. */
|
|
153
|
-
recordSuccess(ip: string): void {
|
|
154
|
-
this.entries.delete(ip);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** For tests: drop all state. */
|
|
158
|
-
reset(): void {
|
|
159
|
-
this.entries.clear();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Current entry count — exposed for tests + observability. */
|
|
163
|
-
size(): number {
|
|
164
|
-
return this.entries.size;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Evict the oldest insertion(s) until size < maxEntries. Map preserves
|
|
169
|
-
* insertion order, so `keys().next().value` is the oldest. We re-insert
|
|
170
|
-
* on window-rollover (delete + new set), so insertion order tracks
|
|
171
|
-
* recency-of-failure closely enough for FIFO eviction.
|
|
172
|
-
*/
|
|
173
|
-
private evictIfFull(): void {
|
|
174
|
-
while (this.entries.size >= this.maxEntries) {
|
|
175
|
-
const oldest = this.entries.keys().next().value;
|
|
176
|
-
if (oldest === undefined) break;
|
|
177
|
-
this.entries.delete(oldest);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Singleton rate limiter — kept for back-compat with callers that don't pass
|
|
184
|
-
* through per-vault routing. Fresh callers should prefer
|
|
185
|
-
* `getAuthorizeRateLimiter(vaultName)` so traffic on one vault's consent flow
|
|
186
|
-
* doesn't lock out IPs on another vault's consent flow (#93).
|
|
187
|
-
*
|
|
188
|
-
* @deprecated Use `getAuthorizeRateLimiter(vaultName)` instead. The singleton
|
|
189
|
-
* cross-pollutes per-vault consent traffic — one vault under brute-force can
|
|
190
|
-
* lock out IPs on every other vault's consent page.
|
|
191
|
-
*/
|
|
192
|
-
export const authorizeRateLimit = new RateLimiter();
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Per-vault rate limiter registry. The vault count is admin-bounded (vaults
|
|
196
|
-
* are created via CLI, not by clients) so this Map can grow only with operator
|
|
197
|
-
* action — no attacker-driven growth path. Each instance carries the
|
|
198
|
-
* default 10,000-entry IP cap, scoped to its vault (#93).
|
|
199
|
-
*/
|
|
200
|
-
const vaultAuthorizeRateLimiters = new Map<string, RateLimiter>();
|
|
201
|
-
|
|
202
|
-
/** Lazily get-or-create the rate limiter for a given vault. */
|
|
203
|
-
export function getAuthorizeRateLimiter(vaultName: string): RateLimiter {
|
|
204
|
-
let limiter = vaultAuthorizeRateLimiters.get(vaultName);
|
|
205
|
-
if (!limiter) {
|
|
206
|
-
limiter = new RateLimiter();
|
|
207
|
-
vaultAuthorizeRateLimiters.set(vaultName, limiter);
|
|
208
|
-
}
|
|
209
|
-
return limiter;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** For tests: drop all per-vault limiters. */
|
|
213
|
-
export function resetVaultAuthorizeRateLimiters(): void {
|
|
214
|
-
vaultAuthorizeRateLimiters.clear();
|
|
215
|
-
}
|
package/src/routing.test.ts
CHANGED
|
@@ -455,19 +455,21 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
455
455
|
expect(res.status).toBe(401);
|
|
456
456
|
});
|
|
457
457
|
|
|
458
|
-
test("/vault/<name>/oauth
|
|
458
|
+
test("/vault/<name>/oauth/* returns 410 Gone (standalone issuer retired — workstream E)", async () => {
|
|
459
|
+
// The standalone OAuth issuer on vault was removed in vault#366 once hub
|
|
460
|
+
// became required. The 410 carries a pointer to the protected-resource
|
|
461
|
+
// metadata so a confused client can rediscover the new issuer (the hub).
|
|
459
462
|
createVault("journal");
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
new Request(`http://localhost:1940${path}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
expect([201, 400]).toContain(res.status);
|
|
463
|
+
for (const subpath of ["/oauth/register", "/oauth/authorize", "/oauth/token"]) {
|
|
464
|
+
const path = `/vault/journal${subpath}`;
|
|
465
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
466
|
+
expect(res.status).toBe(410);
|
|
467
|
+
const body = (await res.json()) as { error: string; protected_resource_metadata: string };
|
|
468
|
+
expect(body.error).toBe("oauth_endpoint_removed");
|
|
469
|
+
expect(body.protected_resource_metadata).toBe(
|
|
470
|
+
"http://localhost:1940/vault/journal/.well-known/oauth-protected-resource",
|
|
471
|
+
);
|
|
472
|
+
}
|
|
471
473
|
});
|
|
472
474
|
|
|
473
475
|
test("unknown vault returns 404 before hitting auth", async () => {
|
|
@@ -475,7 +477,6 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
475
477
|
for (const path of [
|
|
476
478
|
"/vault/nonexistent/mcp",
|
|
477
479
|
"/vault/nonexistent/api/notes",
|
|
478
|
-
"/vault/nonexistent/oauth/register",
|
|
479
480
|
]) {
|
|
480
481
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
481
482
|
expect(res.status).toBe(404);
|
|
@@ -591,15 +592,22 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
591
592
|
// ---------------------------------------------------------------------------
|
|
592
593
|
// Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
|
|
593
594
|
//
|
|
594
|
-
//
|
|
595
|
+
// After workstream E (2026-05-25), vault is resource-server-only — hub is
|
|
596
|
+
// the OAuth issuer. The discovery endpoints stay served at
|
|
595
597
|
// /vault/<name>/.well-known/oauth-protected-resource
|
|
596
598
|
// /vault/<name>/.well-known/oauth-authorization-server
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
+
// but the metadata they return forwards every authorization-server endpoint
|
|
600
|
+
// to the hub origin. The `resource` URL still names the vault's MCP path
|
|
601
|
+
// (that's the resource being protected); the AS pointer is the hub.
|
|
602
|
+
//
|
|
603
|
+
// `PARACHUTE_HUB_ORIGIN` defaults to `http://127.0.0.1:1939` when unset
|
|
604
|
+
// (canonical hub loopback). Tests below assert against that default.
|
|
599
605
|
// ---------------------------------------------------------------------------
|
|
600
606
|
|
|
601
|
-
|
|
602
|
-
|
|
607
|
+
const HUB_ORIGIN = "http://127.0.0.1:1939";
|
|
608
|
+
|
|
609
|
+
describe("per-vault OAuth discovery (hub-rooted after workstream E)", () => {
|
|
610
|
+
test("AS metadata names the hub as issuer + endpoints", async () => {
|
|
603
611
|
createVault("journal");
|
|
604
612
|
const path = "/vault/journal/.well-known/oauth-authorization-server";
|
|
605
613
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
@@ -609,21 +617,23 @@ describe("per-vault OAuth discovery", () => {
|
|
|
609
617
|
authorization_endpoint: string;
|
|
610
618
|
token_endpoint: string;
|
|
611
619
|
registration_endpoint: string;
|
|
620
|
+
jwks_uri: string;
|
|
612
621
|
};
|
|
613
|
-
expect(body.issuer).toBe(
|
|
614
|
-
expect(body.authorization_endpoint).toBe(
|
|
615
|
-
expect(body.token_endpoint).toBe(
|
|
616
|
-
expect(body.registration_endpoint).toBe(
|
|
622
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
623
|
+
expect(body.authorization_endpoint).toBe(`${HUB_ORIGIN}/oauth/authorize`);
|
|
624
|
+
expect(body.token_endpoint).toBe(`${HUB_ORIGIN}/oauth/token`);
|
|
625
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
626
|
+
expect(body.jwks_uri).toBe(`${HUB_ORIGIN}/.well-known/jwks.json`);
|
|
617
627
|
});
|
|
618
628
|
|
|
619
|
-
test("
|
|
629
|
+
test("PRM names the vault's MCP endpoint and points at the hub as AS", async () => {
|
|
620
630
|
createVault("journal");
|
|
621
631
|
const path = "/vault/journal/.well-known/oauth-protected-resource";
|
|
622
632
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
623
633
|
expect(res.status).toBe(200);
|
|
624
634
|
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
625
635
|
expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
|
|
626
|
-
expect(body.authorization_servers).toEqual([
|
|
636
|
+
expect(body.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
627
637
|
});
|
|
628
638
|
|
|
629
639
|
test("unknown vault returns 404 rather than boilerplate metadata", async () => {
|
|
@@ -637,7 +647,28 @@ describe("per-vault OAuth discovery", () => {
|
|
|
637
647
|
}
|
|
638
648
|
});
|
|
639
649
|
|
|
640
|
-
test("x-forwarded-* headers propagate into the
|
|
650
|
+
test("x-forwarded-* headers propagate into the PRM `resource` URL", async () => {
|
|
651
|
+
// The resource URL still tracks the vault's external origin (it's the
|
|
652
|
+
// protected resource the client just hit); the AS pointer is always the
|
|
653
|
+
// hub, independent of the inbound request's origin.
|
|
654
|
+
createVault("journal");
|
|
655
|
+
const path = "/vault/journal/.well-known/oauth-protected-resource";
|
|
656
|
+
const res = await route(
|
|
657
|
+
new Request(`http://127.0.0.1:1940${path}`, {
|
|
658
|
+
headers: {
|
|
659
|
+
"x-forwarded-host": "vault.example.com",
|
|
660
|
+
"x-forwarded-proto": "https",
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
path,
|
|
664
|
+
);
|
|
665
|
+
expect(res.status).toBe(200);
|
|
666
|
+
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
667
|
+
expect(body.resource).toBe("https://vault.example.com/vault/journal/mcp");
|
|
668
|
+
expect(body.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("AS metadata ignores x-forwarded-* (hub origin is config, not request-derived)", async () => {
|
|
641
672
|
createVault("journal");
|
|
642
673
|
const path = "/vault/journal/.well-known/oauth-authorization-server";
|
|
643
674
|
const res = await route(
|
|
@@ -651,16 +682,16 @@ describe("per-vault OAuth discovery", () => {
|
|
|
651
682
|
);
|
|
652
683
|
expect(res.status).toBe(200);
|
|
653
684
|
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
654
|
-
expect(body.issuer).toBe(
|
|
655
|
-
expect(body.registration_endpoint).toBe(
|
|
656
|
-
"https://vault.example.com/vault/journal/oauth/register",
|
|
657
|
-
);
|
|
685
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
686
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
658
687
|
});
|
|
659
688
|
|
|
660
|
-
test("end-to-end flow: WWW-Authenticate → PRM → AS metadata
|
|
661
|
-
// On 401, follow the challenge to the PRM, then
|
|
662
|
-
// PRM.authorization_servers[0]
|
|
663
|
-
//
|
|
689
|
+
test("end-to-end flow: WWW-Authenticate → PRM → hub AS metadata pointer", async () => {
|
|
690
|
+
// On 401, follow the challenge to the PRM, then read
|
|
691
|
+
// PRM.authorization_servers[0] — it must point at the hub origin. The
|
|
692
|
+
// hub side of the test (fetching the AS metadata at the hub itself)
|
|
693
|
+
// lives in hub's test suite; here we just verify vault stops at the
|
|
694
|
+
// right pointer.
|
|
664
695
|
createVault("journal");
|
|
665
696
|
|
|
666
697
|
// Step 1: unauthenticated MCP → 401 + WWW-Authenticate.
|
|
@@ -675,29 +706,9 @@ describe("per-vault OAuth discovery", () => {
|
|
|
675
706
|
const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
|
|
676
707
|
expect(prmRes.status).toBe(200);
|
|
677
708
|
const prm = (await prmRes.json()) as { authorization_servers: string[] };
|
|
678
|
-
const asBase = prm.authorization_servers[0]; // "http://localhost:1940/vault/journal"
|
|
679
709
|
|
|
680
|
-
// Step 3: AS
|
|
681
|
-
|
|
682
|
-
const asMetaPath = `${asBasePath}/.well-known/oauth-authorization-server`;
|
|
683
|
-
const asRes = await route(new Request(`http://localhost:1940${asMetaPath}`), asMetaPath);
|
|
684
|
-
expect(asRes.status).toBe(200);
|
|
685
|
-
const asMeta = (await asRes.json()) as { registration_endpoint: string };
|
|
686
|
-
|
|
687
|
-
// Step 4: the advertised registration_endpoint must be live.
|
|
688
|
-
const regPath = new URL(asMeta.registration_endpoint).pathname;
|
|
689
|
-
const regRes = await route(
|
|
690
|
-
new Request(`http://localhost:1940${regPath}`, {
|
|
691
|
-
method: "POST",
|
|
692
|
-
headers: { "Content-Type": "application/json" },
|
|
693
|
-
body: JSON.stringify({
|
|
694
|
-
client_name: "Test",
|
|
695
|
-
redirect_uris: ["https://example.com/cb"],
|
|
696
|
-
}),
|
|
697
|
-
}),
|
|
698
|
-
regPath,
|
|
699
|
-
);
|
|
700
|
-
expect(regRes.status).toBe(201);
|
|
710
|
+
// Step 3: the AS pointer is the hub. Clients drive the flow from there.
|
|
711
|
+
expect(prm.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
701
712
|
});
|
|
702
713
|
});
|
|
703
714
|
|
|
@@ -716,7 +727,7 @@ describe("per-vault OAuth discovery", () => {
|
|
|
716
727
|
// ---------------------------------------------------------------------------
|
|
717
728
|
|
|
718
729
|
describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
719
|
-
test("AS metadata at path-insertion short form
|
|
730
|
+
test("AS metadata at path-insertion short form names the hub", async () => {
|
|
720
731
|
createVault("journal");
|
|
721
732
|
const path = "/.well-known/oauth-authorization-server/vault/journal";
|
|
722
733
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
@@ -727,10 +738,10 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
727
738
|
token_endpoint: string;
|
|
728
739
|
registration_endpoint: string;
|
|
729
740
|
};
|
|
730
|
-
expect(body.issuer).toBe(
|
|
731
|
-
expect(body.authorization_endpoint).toBe(
|
|
732
|
-
expect(body.token_endpoint).toBe(
|
|
733
|
-
expect(body.registration_endpoint).toBe(
|
|
741
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
742
|
+
expect(body.authorization_endpoint).toBe(`${HUB_ORIGIN}/oauth/authorize`);
|
|
743
|
+
expect(body.token_endpoint).toBe(`${HUB_ORIGIN}/oauth/token`);
|
|
744
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
734
745
|
});
|
|
735
746
|
|
|
736
747
|
test("AS metadata at path-insertion long form (/mcp suffix) also works", async () => {
|
|
@@ -741,7 +752,7 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
741
752
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
742
753
|
expect(res.status).toBe(200);
|
|
743
754
|
const body = (await res.json()) as { issuer: string };
|
|
744
|
-
expect(body.issuer).toBe(
|
|
755
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
745
756
|
});
|
|
746
757
|
|
|
747
758
|
test("PRM at path-insertion short form returns vault-scoped resource", async () => {
|
|
@@ -751,7 +762,7 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
751
762
|
expect(res.status).toBe(200);
|
|
752
763
|
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
753
764
|
expect(body.resource).toBe("http://localhost:1940/vault/journal/mcp");
|
|
754
|
-
expect(body.authorization_servers).toEqual([
|
|
765
|
+
expect(body.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
755
766
|
});
|
|
756
767
|
|
|
757
768
|
test("PRM at path-insertion long form (/mcp suffix) also works", async () => {
|
|
@@ -808,7 +819,9 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
808
819
|
}
|
|
809
820
|
});
|
|
810
821
|
|
|
811
|
-
test("
|
|
822
|
+
test("AS metadata at path-insertion is hub-rooted regardless of x-forwarded-*", async () => {
|
|
823
|
+
// Hub origin is configuration, not request-derived. Proxies forwarding
|
|
824
|
+
// x-forwarded-host don't override the issuer.
|
|
812
825
|
createVault("journal");
|
|
813
826
|
const path = "/.well-known/oauth-authorization-server/vault/journal";
|
|
814
827
|
const res = await route(
|
|
@@ -822,16 +835,17 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
822
835
|
);
|
|
823
836
|
expect(res.status).toBe(200);
|
|
824
837
|
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
825
|
-
expect(body.issuer).toBe(
|
|
826
|
-
expect(body.registration_endpoint).toBe(
|
|
827
|
-
"https://vault.example.com/vault/journal/oauth/register",
|
|
828
|
-
);
|
|
838
|
+
expect(body.issuer).toBe(HUB_ORIGIN);
|
|
839
|
+
expect(body.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
829
840
|
});
|
|
830
841
|
|
|
831
|
-
test("end-to-end: 401 → PRM (path-insertion) → AS
|
|
832
|
-
// Exact handshake Claude Code's MCP OAuth SDK performs.
|
|
833
|
-
//
|
|
834
|
-
//
|
|
842
|
+
test("end-to-end: 401 → PRM (path-insertion) → AS pointer is the hub", async () => {
|
|
843
|
+
// Exact handshake Claude Code's MCP OAuth SDK performs. After
|
|
844
|
+
// workstream E the chain stops at "AS = hub origin"; the live
|
|
845
|
+
// registration_endpoint lives on the hub (covered by hub's tests).
|
|
846
|
+
// Both the path-append PRM (named by the WWW-Authenticate challenge)
|
|
847
|
+
// and the path-insertion PRM (what strict clients probe) must
|
|
848
|
+
// return the same hub pointer.
|
|
835
849
|
createVault("journal");
|
|
836
850
|
|
|
837
851
|
// Step 1: unauth MCP → 401 + WWW-Authenticate with PRM pointer.
|
|
@@ -841,9 +855,7 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
841
855
|
const challenge = mcpRes.headers.get("WWW-Authenticate")!;
|
|
842
856
|
const prmUrl = challenge.match(/resource_metadata="([^"]+)"/)![1];
|
|
843
857
|
|
|
844
|
-
//
|
|
845
|
-
// in the header). Strict clients ignore the hint and probe the
|
|
846
|
-
// path-insertion form regardless — that's the path we care about here.
|
|
858
|
+
// Step 2: path-insertion PRM also resolves and names the hub.
|
|
847
859
|
const prmInsertPath = "/.well-known/oauth-protected-resource/vault/journal";
|
|
848
860
|
const prmRes = await route(
|
|
849
861
|
new Request(`http://localhost:1940${prmInsertPath}`),
|
|
@@ -851,41 +863,30 @@ describe("OAuth discovery (RFC 8414/9728 path-insertion form)", () => {
|
|
|
851
863
|
);
|
|
852
864
|
expect(prmRes.status).toBe(200);
|
|
853
865
|
const prm = (await prmRes.json()) as { authorization_servers: string[] };
|
|
854
|
-
|
|
866
|
+
expect(prm.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
855
867
|
|
|
856
|
-
// Step
|
|
857
|
-
//
|
|
858
|
-
|
|
859
|
-
const asInsertPath = `/.well-known/oauth-authorization-server
|
|
868
|
+
// Step 3: AS metadata via path-insertion on the vault path returns
|
|
869
|
+
// hub-rooted endpoints — strict clients that probe AS via the vault
|
|
870
|
+
// path (rather than following the PRM pointer) still land at the hub.
|
|
871
|
+
const asInsertPath = `/.well-known/oauth-authorization-server/vault/journal`;
|
|
860
872
|
const asRes = await route(
|
|
861
873
|
new Request(`http://localhost:1940${asInsertPath}`),
|
|
862
874
|
asInsertPath,
|
|
863
875
|
);
|
|
864
876
|
expect(asRes.status).toBe(200);
|
|
865
|
-
const asMeta = (await asRes.json()) as { registration_endpoint: string };
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const regPath = new URL(asMeta.registration_endpoint).pathname;
|
|
869
|
-
const regRes = await route(
|
|
870
|
-
new Request(`http://localhost:1940${regPath}`, {
|
|
871
|
-
method: "POST",
|
|
872
|
-
headers: { "Content-Type": "application/json" },
|
|
873
|
-
body: JSON.stringify({
|
|
874
|
-
client_name: "Test",
|
|
875
|
-
redirect_uris: ["https://example.com/cb"],
|
|
876
|
-
}),
|
|
877
|
-
}),
|
|
878
|
-
regPath,
|
|
879
|
-
);
|
|
880
|
-
expect(regRes.status).toBe(201);
|
|
877
|
+
const asMeta = (await asRes.json()) as { issuer: string; registration_endpoint: string };
|
|
878
|
+
expect(asMeta.issuer).toBe(HUB_ORIGIN);
|
|
879
|
+
expect(asMeta.registration_endpoint).toBe(`${HUB_ORIGIN}/oauth/register`);
|
|
881
880
|
|
|
882
|
-
//
|
|
883
|
-
//
|
|
881
|
+
// Step 4: path-append PRM URL in the challenge header still resolves
|
|
882
|
+
// and agrees with the path-insertion answer.
|
|
884
883
|
const prmAppendRes = await route(
|
|
885
884
|
new Request(prmUrl),
|
|
886
885
|
new URL(prmUrl).pathname,
|
|
887
886
|
);
|
|
888
887
|
expect(prmAppendRes.status).toBe(200);
|
|
888
|
+
const prmAppend = (await prmAppendRes.json()) as { authorization_servers: string[] };
|
|
889
|
+
expect(prmAppend.authorization_servers).toEqual([HUB_ORIGIN]);
|
|
889
890
|
});
|
|
890
891
|
});
|
|
891
892
|
|
package/src/routing.ts
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* /vaults/list — public vault-name discovery (can be
|
|
16
16
|
* disabled globally via config)
|
|
17
17
|
* /vaults — authenticated vault metadata list
|
|
18
|
-
* /vault/<name>/.well-known
|
|
19
|
-
*
|
|
18
|
+
* /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
|
|
19
|
+
* the hub as the authorization server
|
|
20
|
+
* (vault is resource-server only)
|
|
20
21
|
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
21
22
|
* /vault/<name>/view/<idOrPath> — auth-aware HTML view
|
|
22
23
|
* /vault/<name>/public/<noteId> — legacy alias → /view redirect
|
|
@@ -26,6 +27,13 @@
|
|
|
26
27
|
* There is deliberately no compat for the old `/api/*`, `/mcp`, `/oauth/*`,
|
|
27
28
|
* `/view/*`, or `/vaults/<name>/*` prefixes. Clients must re-authenticate
|
|
28
29
|
* after the upgrade and point at the new URLs.
|
|
30
|
+
*
|
|
31
|
+
* **No standalone OAuth issuer.** vault does not mint OAuth tokens or
|
|
32
|
+
* render a consent UI. Hub is the issuer; vault validates hub-signed
|
|
33
|
+
* JWTs (see `auth.ts` + `hub-jwt.ts`). The discovery endpoints above are
|
|
34
|
+
* forwarders that point clients at the hub. The standalone surface that
|
|
35
|
+
* lived in `src/oauth.ts` was retired in vault#366 (workstream E of the
|
|
36
|
+
* UX audit, 2026-05-25).
|
|
29
37
|
*/
|
|
30
38
|
|
|
31
39
|
import pkg from "../package.json" with { type: "json" };
|
|
@@ -60,15 +68,10 @@ import { handleTokens } from "./tokens-routes.ts";
|
|
|
60
68
|
import {
|
|
61
69
|
handleProtectedResource,
|
|
62
70
|
handleAuthorizationServer,
|
|
63
|
-
handleRegister,
|
|
64
|
-
handleAuthorizeGet,
|
|
65
|
-
handleAuthorizePost,
|
|
66
|
-
handleToken,
|
|
67
71
|
getBaseUrl,
|
|
68
|
-
} from "./oauth.ts";
|
|
72
|
+
} from "./oauth-discovery.ts";
|
|
69
73
|
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
70
74
|
import { buildAuthStatus } from "./auth-status.ts";
|
|
71
|
-
import { getAuthorizeRateLimiter } from "./owner-auth.ts";
|
|
72
75
|
import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
|
|
73
76
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
74
77
|
|
|
@@ -167,7 +170,6 @@ function handleParachuteIcon(): Response {
|
|
|
167
170
|
export async function route(
|
|
168
171
|
req: Request,
|
|
169
172
|
path: string,
|
|
170
|
-
clientIp?: string,
|
|
171
173
|
): Promise<Response> {
|
|
172
174
|
// ---------------------------------------------------------------------
|
|
173
175
|
// OAuth discovery — RFC 8414 §3.1 / RFC 9728 §3 path-insertion form.
|
|
@@ -325,41 +327,25 @@ export async function route(
|
|
|
325
327
|
});
|
|
326
328
|
}
|
|
327
329
|
|
|
328
|
-
//
|
|
330
|
+
// The legacy `/oauth/{register,authorize,token}` flow on vault was retired
|
|
331
|
+
// when the standalone OAuth issuer was removed (vault#366, workstream E of
|
|
332
|
+
// the UX audit). Hub is the issuer now; vault is resource-server only. A
|
|
333
|
+
// request landing here is from a client that hasn't been re-pointed at the
|
|
334
|
+
// hub yet — surface a clear 410 Gone with a discovery pointer so the client
|
|
335
|
+
// (or its operator) knows where the authorization server moved.
|
|
329
336
|
if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
ownerPasswordHash,
|
|
343
|
-
totpEnrolled,
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
if (req.method === "POST") {
|
|
347
|
-
return handleAuthorizePost(req, store.db, {
|
|
348
|
-
vaultName: vaultConfig.name,
|
|
349
|
-
clientIp,
|
|
350
|
-
ownerPasswordHash,
|
|
351
|
-
totpSecret,
|
|
352
|
-
// Per-vault rate-limit instance — prevents brute-force traffic on
|
|
353
|
-
// one vault's consent flow from locking out IPs trying to authorize
|
|
354
|
-
// against an unrelated vault (#93).
|
|
355
|
-
rateLimiter: getAuthorizeRateLimiter(vaultConfig.name),
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
359
|
-
}
|
|
360
|
-
// handleToken pins the OAuth code to the issuing vault (prevents
|
|
361
|
-
// cross-vault code replay) and echoes `vault: <name>` in the response.
|
|
362
|
-
if (subpath === "/oauth/token") return handleToken(req, store.db, vaultName);
|
|
337
|
+
const base = getBaseUrl(req);
|
|
338
|
+
return Response.json(
|
|
339
|
+
{
|
|
340
|
+
error: "oauth_endpoint_removed",
|
|
341
|
+
error_description:
|
|
342
|
+
"Vault no longer hosts an OAuth issuer. The hub is the authorization server. " +
|
|
343
|
+
"Discover its endpoints via the protected-resource metadata.",
|
|
344
|
+
protected_resource_metadata:
|
|
345
|
+
`${base}/vault/${vaultName}/.well-known/oauth-protected-resource`,
|
|
346
|
+
},
|
|
347
|
+
{ status: 410 },
|
|
348
|
+
);
|
|
363
349
|
}
|
|
364
350
|
|
|
365
351
|
// Parachute service-info + icon (no auth, CORS *). The CLI hub page at
|
package/src/server.ts
CHANGED
|
@@ -293,20 +293,9 @@ const server = Bun.serve({
|
|
|
293
293
|
return new Response(null, { status: 204, headers: corsHeaders });
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
// Derive client IP. Default: socket IP only (safe for direct-internet
|
|
297
|
-
// deployments). If TRUST_PROXY=1 is set, honor X-Forwarded-For — use
|
|
298
|
-
// this only when a reverse proxy (Cloudflare Tunnel, nginx, etc.) is
|
|
299
|
-
// terminating the connection, otherwise attackers can spoof the header
|
|
300
|
-
// to evade per-IP rate limiting.
|
|
301
|
-
const trustProxy = process.env.TRUST_PROXY === "1" || process.env.TRUST_PROXY === "true";
|
|
302
|
-
const forwardedFor = trustProxy ? req.headers.get("x-forwarded-for") : null;
|
|
303
|
-
const clientIp = forwardedFor
|
|
304
|
-
? forwardedFor.split(",")[0]!.trim()
|
|
305
|
-
: server.requestIP(req)?.address;
|
|
306
|
-
|
|
307
296
|
try {
|
|
308
297
|
const start = Date.now();
|
|
309
|
-
const response = await route(req, path
|
|
298
|
+
const response = await route(req, path);
|
|
310
299
|
const ms = Date.now() - start;
|
|
311
300
|
console.log(`${req.method} ${path} ${response.status} ${ms}ms`);
|
|
312
301
|
for (const [k, v] of Object.entries(corsHeaders)) {
|