@openparachute/hub 0.6.5-rc.1 → 0.6.5-rc.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.5-rc.1",
3
+ "version": "0.6.5-rc.2",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -578,6 +578,298 @@ describe("handleAuthorizeGet", () => {
578
578
  });
579
579
  });
580
580
 
581
+ // hub#570 — open-redirect: protocol errors (unsupported_response_type,
582
+ // invalid_request for non-S256 PKCE) MUST NOT redirect to the supplied
583
+ // redirect_uri until the (client_id, redirect_uri) pair is confirmed
584
+ // registered. RFC 6749 §4.1.2.1: an unvalidated redirect_uri error is shown
585
+ // to the user, never redirected. Pre-fix, an attacker with a valid client_id
586
+ // + crafted redirect_uri + bad response_type got an error redirect to the
587
+ // attacker-controlled URI.
588
+ describe("handleAuthorizeGet — error redirects gated on redirect_uri validation (hub#570)", () => {
589
+ test("invalid response_type + UNREGISTERED redirect_uri → HTML error, no redirect", async () => {
590
+ const { db, cleanup } = await makeDb();
591
+ try {
592
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
593
+ const { challenge } = makePkce();
594
+ const req = new Request(
595
+ authorizeUrl({
596
+ client_id: reg.client.clientId,
597
+ // Crafted attacker-controlled URI, NOT registered for this client.
598
+ redirect_uri: "https://evil.example/steal",
599
+ response_type: "token",
600
+ code_challenge: challenge,
601
+ code_challenge_method: "S256",
602
+ state: "abc",
603
+ }),
604
+ );
605
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
606
+ // Must be the HTML "Redirect mismatch" error page, NOT a 302 to evil.
607
+ expect(res.status).toBe(400);
608
+ expect(res.headers.get("location")).toBeNull();
609
+ const body = await res.text();
610
+ expect(body).toContain("Redirect mismatch");
611
+ } finally {
612
+ cleanup();
613
+ }
614
+ });
615
+
616
+ test("invalid code_challenge_method + UNREGISTERED redirect_uri → HTML error, no redirect", async () => {
617
+ const { db, cleanup } = await makeDb();
618
+ try {
619
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
620
+ const req = new Request(
621
+ authorizeUrl({
622
+ client_id: reg.client.clientId,
623
+ redirect_uri: "https://evil.example/steal",
624
+ response_type: "code",
625
+ code_challenge: "challenge",
626
+ code_challenge_method: "plain",
627
+ state: "abc",
628
+ }),
629
+ );
630
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
631
+ expect(res.status).toBe(400);
632
+ expect(res.headers.get("location")).toBeNull();
633
+ const body = await res.text();
634
+ expect(body).toContain("Redirect mismatch");
635
+ } finally {
636
+ cleanup();
637
+ }
638
+ });
639
+
640
+ test("invalid response_type + VALID registered redirect_uri → error redirect (spec-correct)", async () => {
641
+ const { db, cleanup } = await makeDb();
642
+ try {
643
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
644
+ const { challenge } = makePkce();
645
+ const req = new Request(
646
+ authorizeUrl({
647
+ client_id: reg.client.clientId,
648
+ redirect_uri: "https://app.example/cb",
649
+ response_type: "token",
650
+ code_challenge: challenge,
651
+ code_challenge_method: "S256",
652
+ state: "abc",
653
+ }),
654
+ );
655
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
656
+ // Pair is registered → redirecting the protocol error is RFC-correct.
657
+ expect(res.status).toBe(302);
658
+ const loc = new URL(res.headers.get("location") ?? "");
659
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
660
+ expect(loc.searchParams.get("error")).toBe("unsupported_response_type");
661
+ expect(loc.searchParams.get("state")).toBe("abc");
662
+ } finally {
663
+ cleanup();
664
+ }
665
+ });
666
+
667
+ test("invalid code_challenge_method + VALID registered redirect_uri → error redirect (spec-correct)", async () => {
668
+ const { db, cleanup } = await makeDb();
669
+ try {
670
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
671
+ const req = new Request(
672
+ authorizeUrl({
673
+ client_id: reg.client.clientId,
674
+ redirect_uri: "https://app.example/cb",
675
+ response_type: "code",
676
+ code_challenge: "challenge",
677
+ code_challenge_method: "plain",
678
+ state: "abc",
679
+ }),
680
+ );
681
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
682
+ expect(res.status).toBe(302);
683
+ const loc = new URL(res.headers.get("location") ?? "");
684
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
685
+ expect(loc.searchParams.get("error")).toBe("invalid_request");
686
+ expect(loc.searchParams.get("state")).toBe("abc");
687
+ } finally {
688
+ cleanup();
689
+ }
690
+ });
691
+
692
+ test("unknown client_id + invalid response_type → HTML error, no redirect", async () => {
693
+ const { db, cleanup } = await makeDb();
694
+ try {
695
+ const { challenge } = makePkce();
696
+ const req = new Request(
697
+ authorizeUrl({
698
+ client_id: "no-such-client",
699
+ redirect_uri: "https://evil.example/steal",
700
+ response_type: "token",
701
+ code_challenge: challenge,
702
+ code_challenge_method: "S256",
703
+ }),
704
+ );
705
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
706
+ expect(res.status).toBe(400);
707
+ expect(res.headers.get("location")).toBeNull();
708
+ const body = await res.text();
709
+ expect(body).toContain("Unknown application");
710
+ } finally {
711
+ cleanup();
712
+ }
713
+ });
714
+
715
+ test("valid full flow still reaches consent (regression guard)", async () => {
716
+ const { db, cleanup } = await makeDb();
717
+ try {
718
+ const user = await createUser(db, "owner", "pw");
719
+ const session = createSession(db, { userId: user.id });
720
+ const reg = registerClient(db, {
721
+ redirectUris: ["https://app.example/cb"],
722
+ clientName: "MyApp",
723
+ });
724
+ const { challenge } = makePkce();
725
+ const req = new Request(
726
+ authorizeUrl({
727
+ client_id: reg.client.clientId,
728
+ redirect_uri: "https://app.example/cb",
729
+ response_type: "code",
730
+ code_challenge: challenge,
731
+ code_challenge_method: "S256",
732
+ scope: "vault:read",
733
+ }),
734
+ {
735
+ headers: {
736
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
737
+ },
738
+ },
739
+ );
740
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
741
+ expect(res.status).toBe(200);
742
+ const html = await res.text();
743
+ expect(html).toContain("Approve");
744
+ expect(html).toContain('name="__action" value="consent"');
745
+ } finally {
746
+ cleanup();
747
+ }
748
+ });
749
+
750
+ test("pending client + valid session + bad response_type → NOT promoted to approved, request rejected (#570 reviewer fold)", async () => {
751
+ // The pending-client auto-approve (`approveClient`) is a DB state
752
+ // mutation. It must not fire for a request we're about to reject — so
753
+ // redirect_uri validation (and, transitively, the protocol-error checks)
754
+ // gate it. A malformed `response_type` on a registered-but-pending
755
+ // client must leave the client `pending`, not silently promote it.
756
+ const { db, cleanup } = await makeDb();
757
+ try {
758
+ const user = await createUser(db, "owner", "pw");
759
+ const session = createSession(db, { userId: user.id });
760
+ const reg = registerClient(db, {
761
+ redirectUris: ["https://app.example/cb"],
762
+ clientName: "MyApp",
763
+ status: "pending",
764
+ });
765
+ // Sanity: the client starts pending.
766
+ expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
767
+ const { challenge } = makePkce();
768
+ const req = new Request(
769
+ authorizeUrl({
770
+ client_id: reg.client.clientId,
771
+ // Valid REGISTERED redirect_uri — so the rejection is driven by the
772
+ // malformed response_type, not the redirect mismatch. This isolates
773
+ // the "mutation before full validation" class the fold closes.
774
+ redirect_uri: "https://app.example/cb",
775
+ response_type: "token",
776
+ code_challenge: challenge,
777
+ code_challenge_method: "S256",
778
+ state: "pend-1",
779
+ }),
780
+ {
781
+ headers: {
782
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
783
+ },
784
+ },
785
+ );
786
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
787
+ // Registered redirect_uri → the protocol error redirects (spec-correct).
788
+ expect(res.status).toBe(302);
789
+ const loc = new URL(res.headers.get("location") ?? "");
790
+ expect(loc.searchParams.get("error")).toBe("unsupported_response_type");
791
+ // The crux: the client must STILL be pending — no silent promotion.
792
+ expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
793
+ } finally {
794
+ cleanup();
795
+ }
796
+ });
797
+
798
+ test("pending client + valid session + UNREGISTERED redirect_uri → HTML error, not promoted (#570 reviewer fold)", async () => {
799
+ const { db, cleanup } = await makeDb();
800
+ try {
801
+ const user = await createUser(db, "owner", "pw");
802
+ const session = createSession(db, { userId: user.id });
803
+ const reg = registerClient(db, {
804
+ redirectUris: ["https://app.example/cb"],
805
+ clientName: "MyApp",
806
+ status: "pending",
807
+ });
808
+ const { challenge } = makePkce();
809
+ const req = new Request(
810
+ authorizeUrl({
811
+ client_id: reg.client.clientId,
812
+ redirect_uri: "https://evil.example/steal",
813
+ response_type: "code",
814
+ code_challenge: challenge,
815
+ code_challenge_method: "S256",
816
+ }),
817
+ {
818
+ headers: {
819
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
820
+ },
821
+ },
822
+ );
823
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
824
+ expect(res.status).toBe(400);
825
+ expect(res.headers.get("location")).toBeNull();
826
+ expect(await res.text()).toContain("Redirect mismatch");
827
+ // Still pending — unregistered redirect_uri never promotes the client.
828
+ expect(getClient(db, reg.client.clientId)?.status).toBe("pending");
829
+ } finally {
830
+ cleanup();
831
+ }
832
+ });
833
+
834
+ test("pending client + valid session + valid request → auto-approved → consent (happy path preserved)", async () => {
835
+ const { db, cleanup } = await makeDb();
836
+ try {
837
+ const user = await createUser(db, "owner", "pw");
838
+ const session = createSession(db, { userId: user.id });
839
+ const reg = registerClient(db, {
840
+ redirectUris: ["https://app.example/cb"],
841
+ clientName: "MyApp",
842
+ status: "pending",
843
+ });
844
+ const { challenge } = makePkce();
845
+ const req = new Request(
846
+ authorizeUrl({
847
+ client_id: reg.client.clientId,
848
+ redirect_uri: "https://app.example/cb",
849
+ response_type: "code",
850
+ code_challenge: challenge,
851
+ code_challenge_method: "S256",
852
+ scope: "vault:read",
853
+ }),
854
+ {
855
+ headers: {
856
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
857
+ },
858
+ },
859
+ );
860
+ const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
861
+ // Valid client + valid uri + pending → auto-approve → consent render.
862
+ expect(res.status).toBe(200);
863
+ const html = await res.text();
864
+ expect(html).toContain('name="__action" value="consent"');
865
+ // The valid request DID promote the client (auto-approve still works).
866
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
867
+ } finally {
868
+ cleanup();
869
+ }
870
+ });
871
+ });
872
+
581
873
  // Q1 of 2026-04-28-vault-config-and-scopes.md: an unnamed `vault:<verb>` is
582
874
  // ambiguous, so the consent screen forces the operator to pick a vault before
583
875
  // the JWT is minted. Picked vault rewrites the scope to `vault:<picked>:<verb>`
@@ -7957,6 +8249,171 @@ describe("zero-vault non-admin privesc gate (hub#429 reviewer)", () => {
7957
8249
  });
7958
8250
  });
7959
8251
 
8252
+ // hub#431 — consent UX: a non-admin with zero assigned vaults requesting a
8253
+ // NAMED vault scope (`vault:<name>:read`) renders an enabled Approve button
8254
+ // pre-fix, even though the POST is correctly 400'd. (Unnamed `vault:read` was
8255
+ // already covered by the empty-picker disable; the gap was named scopes,
8256
+ // which carry no picker.) The fix disables Approve + shows explanatory copy.
8257
+ describe("handleAuthorizeGet — zero-vault non-admin named vault scope disables Approve (hub#431)", () => {
8258
+ test("non-admin / zero vaults / named vault scope → Approve disabled + copy", async () => {
8259
+ const { db, cleanup } = await makeDb();
8260
+ try {
8261
+ await createUser(db, "admin-aaron", "pw");
8262
+ const bob = await createUser(db, "bob", "pw", { allowMulti: true });
8263
+ const session = createSession(db, { userId: bob.id });
8264
+ const reg = registerClient(db, {
8265
+ redirectUris: ["https://app.example/cb"],
8266
+ status: "approved",
8267
+ });
8268
+ const { challenge } = makePkce();
8269
+ const req = new Request(
8270
+ authorizeUrl({
8271
+ client_id: reg.client.clientId,
8272
+ redirect_uri: "https://app.example/cb",
8273
+ response_type: "code",
8274
+ // NAMED vault scope — no picker, so pre-fix Approve stayed enabled.
8275
+ scope: "vault:default:read",
8276
+ code_challenge: challenge,
8277
+ code_challenge_method: "S256",
8278
+ }),
8279
+ {
8280
+ headers: {
8281
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
8282
+ },
8283
+ },
8284
+ );
8285
+ const res = handleAuthorizeGet(db, req, {
8286
+ issuer: ISSUER,
8287
+ loadServicesManifest: fixtureLoadServicesManifest,
8288
+ });
8289
+ expect(res.status).toBe(200);
8290
+ const html = await res.text();
8291
+ expect(html).toContain("You have no assigned vaults");
8292
+ expect(html).toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
8293
+ } finally {
8294
+ cleanup();
8295
+ }
8296
+ });
8297
+
8298
+ test("admin / named vault scope → Approve enabled", async () => {
8299
+ const { db, cleanup } = await makeDb();
8300
+ try {
8301
+ const admin = await createUser(db, "admin-aaron", "pw");
8302
+ const session = createSession(db, { userId: admin.id });
8303
+ const reg = registerClient(db, {
8304
+ redirectUris: ["https://app.example/cb"],
8305
+ status: "approved",
8306
+ });
8307
+ const { challenge } = makePkce();
8308
+ const req = new Request(
8309
+ authorizeUrl({
8310
+ client_id: reg.client.clientId,
8311
+ redirect_uri: "https://app.example/cb",
8312
+ response_type: "code",
8313
+ scope: "vault:default:read",
8314
+ code_challenge: challenge,
8315
+ code_challenge_method: "S256",
8316
+ }),
8317
+ {
8318
+ headers: {
8319
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
8320
+ },
8321
+ },
8322
+ );
8323
+ const res = handleAuthorizeGet(db, req, {
8324
+ issuer: ISSUER,
8325
+ loadServicesManifest: fixtureLoadServicesManifest,
8326
+ });
8327
+ expect(res.status).toBe(200);
8328
+ const html = await res.text();
8329
+ expect(html).not.toContain("You have no assigned vaults");
8330
+ expect(html).not.toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
8331
+ } finally {
8332
+ cleanup();
8333
+ }
8334
+ });
8335
+
8336
+ test("non-admin WITH an assigned vault / named vault scope → Approve enabled", async () => {
8337
+ const { db, cleanup } = await makeDb();
8338
+ try {
8339
+ await createUser(db, "admin-aaron", "pw");
8340
+ const bob = await createUser(db, "bob", "pw", { allowMulti: true });
8341
+ setUserVaults(db, bob.id, ["default"]);
8342
+ const session = createSession(db, { userId: bob.id });
8343
+ const reg = registerClient(db, {
8344
+ redirectUris: ["https://app.example/cb"],
8345
+ status: "approved",
8346
+ });
8347
+ const { challenge } = makePkce();
8348
+ const req = new Request(
8349
+ authorizeUrl({
8350
+ client_id: reg.client.clientId,
8351
+ redirect_uri: "https://app.example/cb",
8352
+ response_type: "code",
8353
+ scope: "vault:default:read",
8354
+ code_challenge: challenge,
8355
+ code_challenge_method: "S256",
8356
+ }),
8357
+ {
8358
+ headers: {
8359
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
8360
+ },
8361
+ },
8362
+ );
8363
+ const res = handleAuthorizeGet(db, req, {
8364
+ issuer: ISSUER,
8365
+ loadServicesManifest: fixtureLoadServicesManifest,
8366
+ });
8367
+ expect(res.status).toBe(200);
8368
+ const html = await res.text();
8369
+ expect(html).not.toContain("You have no assigned vaults");
8370
+ expect(html).not.toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
8371
+ } finally {
8372
+ cleanup();
8373
+ }
8374
+ });
8375
+
8376
+ test("non-admin / zero vaults / NON-vault scope → Approve enabled", async () => {
8377
+ const { db, cleanup } = await makeDb();
8378
+ try {
8379
+ await createUser(db, "admin-aaron", "pw");
8380
+ const bob = await createUser(db, "bob", "pw", { allowMulti: true });
8381
+ const session = createSession(db, { userId: bob.id });
8382
+ const reg = registerClient(db, {
8383
+ redirectUris: ["https://app.example/cb"],
8384
+ status: "approved",
8385
+ });
8386
+ const { challenge } = makePkce();
8387
+ const req = new Request(
8388
+ authorizeUrl({
8389
+ client_id: reg.client.clientId,
8390
+ redirect_uri: "https://app.example/cb",
8391
+ response_type: "code",
8392
+ // Non-vault scope — the user can still consent without a vault.
8393
+ scope: "scribe:transcribe",
8394
+ code_challenge: challenge,
8395
+ code_challenge_method: "S256",
8396
+ }),
8397
+ {
8398
+ headers: {
8399
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
8400
+ },
8401
+ },
8402
+ );
8403
+ const res = handleAuthorizeGet(db, req, {
8404
+ issuer: ISSUER,
8405
+ loadServicesManifest: fixtureLoadServicesManifest,
8406
+ });
8407
+ expect(res.status).toBe(200);
8408
+ const html = await res.text();
8409
+ expect(html).not.toContain("You have no assigned vaults");
8410
+ expect(html).not.toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
8411
+ } finally {
8412
+ cleanup();
8413
+ }
8414
+ });
8415
+ });
8416
+
7960
8417
  // RFC 8707 resource binding (fix #461). A friend connecting an MCP client to
7961
8418
  // ONE vault (`<origin>/vault/<name>/mcp`) must see ONLY that vault's scopes on
7962
8419
  // consent, and the minted token must carry the narrow, NAMED scope +
@@ -225,6 +225,33 @@ describe("renderConsent", () => {
225
225
  expect(html).toContain("no vaults exist");
226
226
  expect(html).toContain('value="yes" class="btn btn-primary" disabled');
227
227
  });
228
+
229
+ test("disables Approve + shows copy when user can't authorize (hub#431)", () => {
230
+ const html = renderConsent({
231
+ params: { ...PARAMS, scope: "vault:work:read" },
232
+ csrfToken: CSRF,
233
+ clientId: "c",
234
+ clientName: "App",
235
+ scopes: ["vault:work:read"],
236
+ userCanAuthorizeRequest: false,
237
+ });
238
+ expect(html).toContain("You have no assigned vaults");
239
+ expect(html).toContain("ask the hub admin".replace("ask", "Ask"));
240
+ expect(html).toContain('value="yes" class="btn btn-primary" disabled');
241
+ });
242
+
243
+ test("leaves Approve enabled when userCanAuthorizeRequest is true (hub#431)", () => {
244
+ const html = renderConsent({
245
+ params: { ...PARAMS, scope: "vault:work:read" },
246
+ csrfToken: CSRF,
247
+ clientId: "c",
248
+ clientName: "App",
249
+ scopes: ["vault:work:read"],
250
+ userCanAuthorizeRequest: true,
251
+ });
252
+ expect(html).not.toContain("You have no assigned vaults");
253
+ expect(html).not.toContain('value="yes" class="btn btn-primary" disabled');
254
+ });
228
255
  });
229
256
 
230
257
  describe("renderError", () => {
@@ -861,22 +861,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
861
861
  if ("error" in parsed) {
862
862
  return htmlError("Invalid authorization request", parsed.error, 400);
863
863
  }
864
- if (parsed.responseType !== "code") {
865
- return oauthErrorRedirect(
866
- parsed.redirectUri,
867
- "unsupported_response_type",
868
- "only response_type=code is supported",
869
- parsed.state,
870
- );
871
- }
872
- if (parsed.codeChallengeMethod !== "S256") {
873
- return oauthErrorRedirect(
874
- parsed.redirectUri,
875
- "invalid_request",
876
- "PKCE S256 is required",
877
- parsed.state,
878
- );
879
- }
864
+ // NOTE: response_type / code_challenge_method validation is DELIBERATELY
865
+ // deferred until after the (client_id, redirect_uri) pair is confirmed
866
+ // registered (below, just past `requireRegisteredRedirectUri`). RFC 6749
867
+ // §4.1.2.1: when the redirect_uri can't be validated against the client,
868
+ // errors MUST be shown to the user — NOT redirected to the supplied URI.
869
+ // Redirecting here (pre-validation) on a crafted redirect_uri is an open
870
+ // redirect (hub#570). Once the pair is validated, redirecting these errors
871
+ // back to the now-trusted URI is spec-correct.
880
872
  let client = getClient(db, parsed.clientId);
881
873
  if (!client) {
882
874
  // Can't safely redirect — we don't trust the redirect_uri until we've
@@ -928,6 +920,49 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
928
920
  url.searchParams.set("scope", parsed.scope);
929
921
  }
930
922
 
923
+ // Validate the FULL request BEFORE any state mutation (the pending-client
924
+ // auto-approve below calls `approveClient`). Two reasons, both #570:
925
+ //
926
+ // 1. RFC 6749 §4.1.2.1 — an unvalidated redirect_uri error is shown to
927
+ // the user, never redirected. `requireRegisteredRedirectUri` first
928
+ // confirms the (client_id, redirect_uri) pair is registered; only then
929
+ // may we redirect the protocol errors (response_type / PKCE) to it.
930
+ // 2. We must not permanently promote a pending client to `approved` for a
931
+ // request we're about to reject. Pre-fix the redirect-uri + protocol
932
+ // checks sat BELOW the auto-approve block, so a malformed request
933
+ // (bad response_type / PKCE) against a registered-but-pending client
934
+ // mutated the DB before validation ever ran (#570 reviewer fold).
935
+ //
936
+ // So: redirect_uri registration → response_type → PKCE, all ahead of the
937
+ // pending-client auto-approve.
938
+ try {
939
+ requireRegisteredRedirectUri(client, parsed.redirectUri);
940
+ } catch {
941
+ return htmlError(
942
+ "Redirect mismatch",
943
+ "The redirect_uri does not match any URI registered for this app.",
944
+ 400,
945
+ );
946
+ }
947
+ // The pair is confirmed registered, so redirecting these protocol errors to
948
+ // it is spec-correct (RFC 6749 §4.1.2.1).
949
+ if (parsed.responseType !== "code") {
950
+ return oauthErrorRedirect(
951
+ parsed.redirectUri,
952
+ "unsupported_response_type",
953
+ "only response_type=code is supported",
954
+ parsed.state,
955
+ );
956
+ }
957
+ if (parsed.codeChallengeMethod !== "S256") {
958
+ return oauthErrorRedirect(
959
+ parsed.redirectUri,
960
+ "invalid_request",
961
+ "PKCE S256 is required",
962
+ parsed.state,
963
+ );
964
+ }
965
+
931
966
  if (client.status !== "approved") {
932
967
  // Single-consent change (2026-05-29): the separate operator "approve this
933
968
  // client" gate is retired — the user's OAuth consent IS the authorization.
@@ -995,15 +1030,6 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
995
1030
  }
996
1031
  client = refreshed;
997
1032
  }
998
- try {
999
- requireRegisteredRedirectUri(client, parsed.redirectUri);
1000
- } catch {
1001
- return htmlError(
1002
- "Redirect mismatch",
1003
- "The redirect_uri does not match any URI registered for this app.",
1004
- 400,
1005
- );
1006
- }
1007
1033
 
1008
1034
  // Operator-only scope gate (#96). Reject any request that names a scope
1009
1035
  // we'll never mint via this flow — `parachute:host:admin` and friends.
@@ -2571,6 +2597,23 @@ function consentProps(
2571
2597
  ) {
2572
2598
  const scopes = params.scope.split(" ").filter((s) => s.length > 0);
2573
2599
  const unnamedVerbs = unnamedVaultVerbs(scopes);
2600
+ // Zero-vault non-admin can't authorize a vault-scoped request (hub#431).
2601
+ // The POST handler already 400s this case ("No vaults assigned"); this
2602
+ // flag lets the consent screen render Approve disabled + explain why,
2603
+ // instead of showing an enabled button that lands the user on an error.
2604
+ // Mirrors the vault-scope detection in `handleConsentSubmit`'s zero-vault
2605
+ // gate. Non-vault scopes (`scribe:transcribe`, etc.) stay authorizable.
2606
+ const requestsVaultScope = scopes.some((s) => {
2607
+ if (s === "vault:read" || s === "vault:write" || s === "vault:admin") return true;
2608
+ const parts = s.split(":");
2609
+ return (
2610
+ parts.length === 3 &&
2611
+ parts[0] === "vault" &&
2612
+ parts[2] !== undefined &&
2613
+ VAULT_VERBS.has(parts[2])
2614
+ );
2615
+ });
2616
+ const userCanAuthorizeRequest = userIsAdmin || assignedVaults.length > 0 || !requestsVaultScope;
2574
2617
  // Multi-user Phase 2 PR 2 stale-assignment branch (hub#284 generalized
2575
2618
  // from one vault to N). A non-admin user whose entire vault list has
2576
2619
  // been removed from services.json — admin removed / renamed the vaults
@@ -2696,6 +2739,7 @@ function consentProps(
2696
2739
  // verb against the stale assignment).
2697
2740
  blockApproveForStaleAssignment:
2698
2741
  staleAssignedVault !== undefined && (unnamedVerbs.length > 0 || hasNamedStaleVaultScope),
2742
+ userCanAuthorizeRequest,
2699
2743
  };
2700
2744
  }
2701
2745
 
package/src/oauth-ui.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * module scopes that the hub doesn't know about) render verbatim.
21
21
  * - **No JavaScript.** Entirely form-based. Submit is the only interaction.
22
22
  */
23
- import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
23
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
24
24
  import { renderCsrfHiddenInput } from "./csrf.ts";
25
25
  import { type ScopeExplanation, explainScope } from "./scope-explanations.ts";
26
26
 
@@ -138,6 +138,15 @@ export interface ConsentViewProps {
138
138
  * the user can still proceed.
139
139
  */
140
140
  blockApproveForStaleAssignment?: boolean;
141
+ /**
142
+ * Multi-user (hub#431): false when the signed-in user can't authorize this
143
+ * request at all — a non-admin with zero assigned vaults requesting a vault
144
+ * scope (named or unnamed). The POST handler already 400s this case ("No
145
+ * vaults assigned"); this flag lets the consent screen render Approve
146
+ * disabled + show explanatory copy instead of an enabled button that lands
147
+ * the user on an error page. Defaults to authorizable when omitted.
148
+ */
149
+ userCanAuthorizeRequest?: boolean;
141
150
  }
142
151
 
143
152
  export interface VaultPicker {
@@ -318,6 +327,7 @@ export function renderConsent(props: ConsentViewProps): string {
318
327
  displayVault,
319
328
  staleAssignedVault,
320
329
  blockApproveForStaleAssignment,
330
+ userCanAuthorizeRequest,
321
331
  } = props;
322
332
  // Substitute unnamed `vault:<verb>` rows with the resolved named form so
323
333
  // the operator sees the scope shape that will appear in the token. Raw
@@ -345,11 +355,16 @@ export function renderConsent(props: ConsentViewProps): string {
345
355
  // requested scope actually depends on a vault — non-vault flows (e.g.
346
356
  // `scribe:transcribe` only) keep Approve enabled so the user can still
347
357
  // proceed despite the informational banner.
358
+ // Zero-vault non-admin requesting a vault scope (hub#431): the POST handler
359
+ // refuses with 400 "No vaults assigned", so render Approve disabled rather
360
+ // than leading the user to click into an error page.
361
+ const cannotAuthorize = userCanAuthorizeRequest === false;
348
362
  const approveDisabled =
349
363
  (vaultPicker &&
350
364
  vaultPicker.lockedVault === undefined &&
351
365
  vaultPicker.availableVaults.length === 0) ||
352
- blockApproveForStaleAssignment === true
366
+ blockApproveForStaleAssignment === true ||
367
+ cannotAuthorize
353
368
  ? " disabled"
354
369
  : "";
355
370
  // Banner copy (hub#284). Worded to the *user* — "ask the admin" framing
@@ -366,6 +381,16 @@ export function renderConsent(props: ConsentViewProps): string {
366
381
  again.
367
382
  </p>`
368
383
  : "";
384
+ // No-vaults-assigned banner (hub#431). Shown when a non-admin with zero
385
+ // assigned vaults requests a vault scope — Approve is disabled above, this
386
+ // explains why and points at admin remediation.
387
+ const noVaultsBanner = cannotAuthorize
388
+ ? `<p class="stale-assignment-banner" role="alert">
389
+ <strong>You have no assigned vaults.</strong>
390
+ Ask the hub admin to assign you a vault via <code>/admin/users</code>
391
+ before authorizing vault access.
392
+ </p>`
393
+ : "";
369
394
  const body = `
370
395
  <div class="card">
371
396
  <div class="card-header">
@@ -383,6 +408,7 @@ export function renderConsent(props: ConsentViewProps): string {
383
408
  </p>
384
409
  </div>
385
410
  ${staleBanner}
411
+ ${noVaultsBanner}
386
412
  <section class="scopes">
387
413
  <h2 class="scopes-title">Permissions requested</h2>
388
414
  <ul class="scope-list">${scopeRows}</ul>