@openparachute/vault 0.4.8-rc.8 → 0.4.8

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/src/owner-auth.ts CHANGED
@@ -1,14 +1,29 @@
1
1
  /**
2
- * Owner authentication for the OAuth consent page.
2
+ * Owner-password storage + verification.
3
3
  *
4
- * The "owner" is the person who set up this vault — identified by a password
5
- * stored globally in config.yaml (owner_password_hash). The password is used
6
- * to prove ownership when authorizing third-party OAuth clients.
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
- * Password hashing uses Bun.password (bcrypt, cost 12 by default) no deps.
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
- * Rate limiting is per-IP, in-memory. Acceptable for v1: resets on restart,
11
- * doesn't handle multi-process deployments. Tighten later if needed.
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
- }
@@ -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/register reaches the OAuth handler", async () => {
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 path = "/vault/journal/oauth/register";
461
- const res = await route(
462
- new Request(`http://localhost:1940${path}`, {
463
- method: "POST",
464
- headers: { "Content-Type": "application/json" },
465
- body: JSON.stringify({ client_name: "test", redirect_uris: ["https://x.example/cb"] }),
466
- }),
467
- path,
468
- );
469
- expect(res.status).not.toBe(500);
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
- // For a resource at `/vault/<name>/mcp`, clients fetch metadata from
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
- // All endpoints in the AS metadata are vault-scoped so a client that
598
- // discovers the AS at that URL can drive the full authorization flow.
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
- describe("per-vault OAuth discovery", () => {
602
- test("/vault/<name>/.well-known/oauth-authorization-server returns vault-scoped AS metadata", async () => {
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("http://localhost:1940/vault/journal");
614
- expect(body.authorization_endpoint).toBe("http://localhost:1940/vault/journal/oauth/authorize");
615
- expect(body.token_endpoint).toBe("http://localhost:1940/vault/journal/oauth/token");
616
- expect(body.registration_endpoint).toBe("http://localhost:1940/vault/journal/oauth/register");
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("/vault/<name>/.well-known/oauth-protected-resource returns vault-scoped PRM", async () => {
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(["http://localhost:1940/vault/journal"]);
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 generated metadata URLs", async () => {
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("https://vault.example.com/vault/journal");
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 → registration_endpoint is live", async () => {
661
- // On 401, follow the challenge to the PRM, then follow
662
- // PRM.authorization_servers[0] to the AS metadata, then hit the
663
- // `registration_endpoint`. Every hop must resolve.
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 metadata lives at `{asBase}/.well-known/oauth-authorization-server`.
681
- const asBasePath = new URL(asBase).pathname; // "/vault/journal"
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 returns vault-scoped endpoints", async () => {
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("http://localhost:1940/vault/journal");
731
- expect(body.authorization_endpoint).toBe("http://localhost:1940/vault/journal/oauth/authorize");
732
- expect(body.token_endpoint).toBe("http://localhost:1940/vault/journal/oauth/token");
733
- expect(body.registration_endpoint).toBe("http://localhost:1940/vault/journal/oauth/register");
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("http://localhost:1940/vault/journal");
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(["http://localhost:1940/vault/journal"]);
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("x-forwarded-* headers propagate through path-insertion URLs", async () => {
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("https://vault.example.com/vault/journal");
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 (path-insertion) DCR lands", async () => {
832
- // Exact handshake Claude Code's MCP OAuth SDK performs. If any hop
833
- // 404s, auth cascade-fails this is the launch-blocker regression
834
- // we're fixing.
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
- // The challenge still points at the path-append PRM (we emit one URL
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
- const asBase = prm.authorization_servers[0]; // http://localhost:1940/vault/journal
866
+ expect(prm.authorization_servers).toEqual([HUB_ORIGIN]);
855
867
 
856
- // Step 2: fetch AS metadata via path-insertion. The issuer path is
857
- // everything after the host here, `/vault/journal`.
858
- const asBasePath = new URL(asBase).pathname;
859
- const asInsertPath = `/.well-known/oauth-authorization-server${asBasePath}`;
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
- // Step 3: registration_endpoint must be live.
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
- // Path-append PRM URL in the challenge header must still resolve —
883
- // we haven't broken the back-compat path.
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/* per-vault OAuth discovery
19
- * /vault/<name>/oauth/{register,authorize,token}
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
- // OAuth flow endpoints (no auth these ARE the auth).
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 store = getVaultStore(vaultName);
331
- if (subpath === "/oauth/register") return handleRegister(req, store.db);
332
- if (subpath === "/oauth/authorize") {
333
- const gc = readGlobalConfig();
334
- const ownerPasswordHash = gc.owner_password_hash ?? null;
335
- const totpSecret = gc.totp_secret ?? null;
336
- const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
337
- if (req.method === "GET") {
338
- return handleAuthorizeGet(
339
- req,
340
- store.db,
341
- vaultConfig.name,
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