@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -3,7 +3,8 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { getClient, registerClient } from "../clients.ts";
6
+ import { handleAdminLoginTotpPost } from "../admin-handlers.ts";
7
+ import { approveClient, getClient, registerClient } from "../clients.ts";
7
8
  import { CSRF_COOKIE_NAME } from "../csrf.ts";
8
9
  import { findGrant, recordGrant } from "../grants.ts";
9
10
  import { hubDbPath, openHubDb } from "../hub-db.ts";
@@ -26,8 +27,12 @@ import {
26
27
  protectedResourceMetadata,
27
28
  vaultScopeForUser,
28
29
  } from "../oauth-handlers.ts";
30
+ import { PENDING_LOGIN_COOKIE_NAME, _resetPendingLogins } from "../pending-login.ts";
31
+ import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
29
32
  import type { ServicesManifest } from "../services-manifest.ts";
30
- import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
33
+ import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
34
+ import { _resetTotpReplayCache, generateTotpSecret } from "../totp.ts";
35
+ import { backupCodesRemaining, isTotpEnrolled, persistEnrollment } from "../two-factor-store.ts";
31
36
  import { createUser, setUserVaults } from "../users.ts";
32
37
 
33
38
  const ISSUER = "https://hub.example";
@@ -873,6 +878,223 @@ describe("handleAuthorizePost — login submit", () => {
873
878
  });
874
879
  });
875
880
 
881
+ // --- OAuth-path TOTP gate (hub#473 P0 bypass regression) -------------------
882
+ //
883
+ // The OAuth login POST (`__action=login`) is the more-common sign-in path
884
+ // (every OAuth client: vault, notes-ui, `parachute auth login`). Before the
885
+ // fix it minted a session on password ALONE even for a 2FA-enrolled user —
886
+ // a full TOTP bypass. These tests pin that the OAuth login now diverts to the
887
+ // TOTP challenge and only mints a session after the second factor, resuming
888
+ // the original /oauth/authorize flow.
889
+
890
+ /** Generate a live TOTP code for a base32 secret (matches the hub's params). */
891
+ function liveTotpCode(secretBase32: string, label = "owner"): string {
892
+ // Lazy import via require keeps the top of file clean; otpauth is a dep.
893
+ const OTPAuth = require("otpauth");
894
+ return new OTPAuth.TOTP({
895
+ issuer: "Parachute Hub",
896
+ label,
897
+ algorithm: "SHA1",
898
+ digits: 6,
899
+ period: 30,
900
+ secret: OTPAuth.Secret.fromBase32(secretBase32),
901
+ }).generate();
902
+ }
903
+
904
+ function cookieValueFrom(res: Response, name: string): string | null {
905
+ for (const sc of res.headers.getSetCookie()) {
906
+ if (sc.startsWith(`${name}=`)) return sc.slice(name.length + 1).split(";")[0] ?? "";
907
+ }
908
+ return null;
909
+ }
910
+
911
+ describe("handleAuthorizePost — login submit + 2FA (hub#473 bypass regression)", () => {
912
+ function loginForm(clientId: string, challenge: string, password = "hunter2"): URLSearchParams {
913
+ return new URLSearchParams({
914
+ __action: "login",
915
+ __csrf: TEST_CSRF,
916
+ username: "owner",
917
+ password,
918
+ client_id: clientId,
919
+ redirect_uri: "https://app.example/cb",
920
+ response_type: "code",
921
+ scope: "vault:read",
922
+ code_challenge: challenge,
923
+ code_challenge_method: "S256",
924
+ state: "xyz-state",
925
+ });
926
+ }
927
+
928
+ test("2FA-enrolled user: correct password ALONE does NOT mint a session — diverts to TOTP challenge", async () => {
929
+ const { db, cleanup } = await makeDb();
930
+ _resetPendingLogins();
931
+ _resetTotpReplayCache();
932
+ resetRateLimit();
933
+ try {
934
+ const user = await createUser(db, "owner", "hunter2", { passwordChanged: true });
935
+ await persistEnrollment(db, user.id, generateTotpSecret("owner").secret);
936
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
937
+ const { challenge } = makePkce();
938
+ const req = new Request(`${ISSUER}/oauth/authorize`, {
939
+ method: "POST",
940
+ body: loginForm(reg.client.clientId, challenge),
941
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie: CSRF_COOKIE },
942
+ });
943
+ const res = await handleAuthorizePost(db, req, { issuer: ISSUER });
944
+ // NO session cookie. The bypass was: this used to be 302 + session.
945
+ expect(cookieValueFrom(res, "parachute_hub_session")).toBeNull();
946
+ // Diverts to the TOTP challenge page + sets a pending-login cookie.
947
+ expect(res.status).toBe(200);
948
+ const html = await res.text();
949
+ expect(html).toContain("Two-factor authentication");
950
+ expect(html).toContain('action="/login/2fa"');
951
+ expect(cookieValueFrom(res, PENDING_LOGIN_COOKIE_NAME)).toBeTruthy();
952
+ } finally {
953
+ cleanup();
954
+ }
955
+ });
956
+
957
+ test("full OAuth two-step: password → challenge → correct TOTP → session minted + resumes /oauth/authorize", async () => {
958
+ const { db, cleanup } = await makeDb();
959
+ _resetPendingLogins();
960
+ _resetTotpReplayCache();
961
+ resetRateLimit();
962
+ try {
963
+ const user = await createUser(db, "owner", "hunter2", { passwordChanged: true });
964
+ const { secret } = generateTotpSecret("owner");
965
+ await persistEnrollment(db, user.id, secret);
966
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
967
+ const { challenge } = makePkce();
968
+
969
+ // Step 1 — OAuth login POST (password).
970
+ const loginReq = new Request(`${ISSUER}/oauth/authorize`, {
971
+ method: "POST",
972
+ body: loginForm(reg.client.clientId, challenge),
973
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie: CSRF_COOKIE },
974
+ });
975
+ const loginRes = await handleAuthorizePost(db, loginReq, { issuer: ISSUER });
976
+ expect(loginRes.status).toBe(200);
977
+ const pendingToken = cookieValueFrom(loginRes, PENDING_LOGIN_COOKIE_NAME);
978
+ expect(pendingToken).toBeTruthy();
979
+
980
+ // Step 2 — TOTP at the shared completion path /login/2fa, carrying the
981
+ // pending-login cookie. (No `next` form field — the stored pending-login
982
+ // `next` is the source of truth for the return URL.)
983
+ const code = liveTotpCode(secret);
984
+ const tfBody = new URLSearchParams({ __csrf: TEST_CSRF, code });
985
+ const tfReq = new Request(`${ISSUER}/login/2fa`, {
986
+ method: "POST",
987
+ body: tfBody,
988
+ headers: {
989
+ "content-type": "application/x-www-form-urlencoded",
990
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
991
+ },
992
+ });
993
+ const tfRes = await handleAdminLoginTotpPost(db, tfReq);
994
+ expect(tfRes.status).toBe(302);
995
+ // Session minted now (after the second factor), not before.
996
+ const sessionId = cookieValueFrom(tfRes, "parachute_hub_session");
997
+ expect(sessionId).toBeTruthy();
998
+ expect(findSession(db, sessionId!)).not.toBeNull();
999
+ // Redirect resumes the ORIGINAL OAuth flow with all its query params.
1000
+ const loc = tfRes.headers.get("location") ?? "";
1001
+ expect(loc.startsWith("/oauth/authorize?")).toBe(true);
1002
+ expect(loc).toContain(`client_id=${reg.client.clientId}`);
1003
+ expect(loc).toContain("code_challenge=");
1004
+ expect(loc).toContain("state=xyz-state");
1005
+ expect(loc).toContain("scope=vault");
1006
+ } finally {
1007
+ cleanup();
1008
+ }
1009
+ });
1010
+
1011
+ test("OAuth path: wrong TOTP code → 401, no session; a backup code completes + is consumed", async () => {
1012
+ const { db, cleanup } = await makeDb();
1013
+ _resetPendingLogins();
1014
+ _resetTotpReplayCache();
1015
+ resetRateLimit();
1016
+ try {
1017
+ const user = await createUser(db, "owner", "hunter2", { passwordChanged: true });
1018
+ const { secret } = generateTotpSecret("owner");
1019
+ const { backupCodes } = await persistEnrollment(db, user.id, secret);
1020
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1021
+ const { challenge } = makePkce();
1022
+
1023
+ const loginRes = await handleAuthorizePost(
1024
+ db,
1025
+ new Request(`${ISSUER}/oauth/authorize`, {
1026
+ method: "POST",
1027
+ body: loginForm(reg.client.clientId, challenge),
1028
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie: CSRF_COOKIE },
1029
+ }),
1030
+ { issuer: ISSUER },
1031
+ );
1032
+ const pendingToken = cookieValueFrom(loginRes, PENDING_LOGIN_COOKIE_NAME)!;
1033
+
1034
+ // Wrong code → 401, no session, pending login survives.
1035
+ const badRes = await handleAdminLoginTotpPost(
1036
+ db,
1037
+ new Request(`${ISSUER}/login/2fa`, {
1038
+ method: "POST",
1039
+ body: new URLSearchParams({ __csrf: TEST_CSRF, code: "000000" }),
1040
+ headers: {
1041
+ "content-type": "application/x-www-form-urlencoded",
1042
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
1043
+ },
1044
+ }),
1045
+ );
1046
+ expect(badRes.status).toBe(401);
1047
+ expect(cookieValueFrom(badRes, "parachute_hub_session")).toBeNull();
1048
+
1049
+ // Backup code completes + is consumed.
1050
+ expect(backupCodesRemaining(db, user.id)).toBe(10);
1051
+ const okRes = await handleAdminLoginTotpPost(
1052
+ db,
1053
+ new Request(`${ISSUER}/login/2fa`, {
1054
+ method: "POST",
1055
+ body: new URLSearchParams({ __csrf: TEST_CSRF, code: backupCodes[0]! }),
1056
+ headers: {
1057
+ "content-type": "application/x-www-form-urlencoded",
1058
+ cookie: `${CSRF_COOKIE}; ${PENDING_LOGIN_COOKIE_NAME}=${pendingToken}`,
1059
+ },
1060
+ }),
1061
+ );
1062
+ expect(okRes.status).toBe(302);
1063
+ expect(cookieValueFrom(okRes, "parachute_hub_session")).toBeTruthy();
1064
+ expect((okRes.headers.get("location") ?? "").startsWith("/oauth/authorize?")).toBe(true);
1065
+ expect(backupCodesRemaining(db, user.id)).toBe(9);
1066
+ } finally {
1067
+ cleanup();
1068
+ }
1069
+ });
1070
+
1071
+ test("OAuth path UNCHANGED for a user WITHOUT 2FA — password alone mints a session (no regression)", async () => {
1072
+ const { db, cleanup } = await makeDb();
1073
+ _resetPendingLogins();
1074
+ resetRateLimit();
1075
+ try {
1076
+ const user = await createUser(db, "owner", "hunter2", { passwordChanged: true });
1077
+ expect(isTotpEnrolled(db, user.id)).toBe(false);
1078
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
1079
+ const { challenge } = makePkce();
1080
+ const res = await handleAuthorizePost(
1081
+ db,
1082
+ new Request(`${ISSUER}/oauth/authorize`, {
1083
+ method: "POST",
1084
+ body: loginForm(reg.client.clientId, challenge),
1085
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie: CSRF_COOKIE },
1086
+ }),
1087
+ { issuer: ISSUER },
1088
+ );
1089
+ expect(res.status).toBe(302);
1090
+ expect(res.headers.get("location")).toContain("/oauth/authorize?");
1091
+ expect(cookieValueFrom(res, "parachute_hub_session")).toBeTruthy();
1092
+ } finally {
1093
+ cleanup();
1094
+ }
1095
+ });
1096
+ });
1097
+
876
1098
  describe("handleAuthorizePost — CSRF protection", () => {
877
1099
  test("rejects POST when CSRF cookie is absent", async () => {
878
1100
  const { db, cleanup } = await makeDb();
@@ -4074,7 +4296,14 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4074
4296
  }
4075
4297
  });
4076
4298
 
4077
- test("session valid + matching Origin page renders WITH approve form + CSRF token", async () => {
4299
+ test("session valid + matching Origin on pending client auto-approves + renders CONSENT (single-consent change)", async () => {
4300
+ // Single-consent change (2026-05-29): the separate inline operator-approve
4301
+ // form is retired from the GET-on-pending path. A pending client + valid
4302
+ // session now auto-approves (status → approved, audit-logged) and falls
4303
+ // straight through to the user's consent screen — ONE consent, not a
4304
+ // two-step approve-then-consent. The inline-approve POST endpoint
4305
+ // (`handleApproveClientPost`) still exists for the SPA / cross-origin
4306
+ // surfaces (tested below), but the GET path no longer renders the form.
4078
4307
  const { db, cleanup } = await makeDb();
4079
4308
  try {
4080
4309
  const user = await createUser(db, "owner", "pw");
@@ -4090,31 +4319,21 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4090
4319
  origin: ISSUER,
4091
4320
  },
4092
4321
  });
4093
- const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
4094
- expect(res.status).toBe(403);
4322
+ const res = handleAuthorizeGet(db, req, {
4323
+ issuer: ISSUER,
4324
+ loadServicesManifest: fixtureLoadServicesManifest,
4325
+ });
4326
+ // Consent render (200) — NOT the old 403 approve-pending page.
4327
+ expect(res.status).toBe(200);
4328
+ expect(res.headers.get("content-type")).toContain("text/html");
4095
4329
  const html = await res.text();
4096
- expect(html).toContain("App not yet approved");
4097
- // The form posts to the approve endpoint
4098
- expect(html).toContain('action="/oauth/authorize/approve"');
4099
- expect(html).toContain('name="client_id"');
4100
- expect(html).toContain(`value="${reg.client.clientId}"`);
4101
- // CSRF token present in the form
4102
- expect(html).toContain(`value="${TEST_CSRF}"`);
4103
- // return_to carries the original authorize URL so the post-approve
4104
- // redirect lands the operator back on the same flow.
4105
- expect(html).toContain('name="return_to"');
4106
- expect(html).toContain("/oauth/authorize?");
4107
- expect(html).toContain("rt-208"); // state echoed via return_to URL
4108
- // Display fields present so operator can verify what they're approving.
4330
+ expect(html).toContain("Approve");
4109
4331
  expect(html).toContain("MyApp");
4110
- expect(html).toContain(reg.client.clientId);
4111
- expect(html).toContain("https://app.example/cb");
4112
- // Authed branch shows only the one-click Approve form — the unauth
4113
- // Sign-in CTA and shareable-link block do NOT render here.
4114
- expect(html).not.toContain("Sign in as admin to approve");
4115
- expect(html).not.toContain("Or send this link to your hub admin");
4116
- // CLI hint also gone in this branch (approval-UX rc.19).
4117
- expect(html).not.toContain("parachute auth approve-client");
4332
+ // The inline approve FORM is gone from this path.
4333
+ expect(html).not.toContain('action="/oauth/authorize/approve"');
4334
+ expect(html).not.toContain("App not yet approved");
4335
+ // The pending client was auto-approved (consent IS the authorization).
4336
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
4118
4337
  } finally {
4119
4338
  cleanup();
4120
4339
  }
@@ -4475,10 +4694,13 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4475
4694
  }
4476
4695
  });
4477
4696
 
4478
- test("end-to-end: GET (pending) POST approve GET (now approved) renders consent", async () => {
4479
- // The full redirect chain. Sessions and CSRF carry across all three
4480
- // requests in the same cookie. The final GET sees status=approved and
4481
- // renders the consent screen.
4697
+ test("single-consent: GET (pending) + sessionauto-approves + renders consent in ONE step", async () => {
4698
+ // Single-consent change (2026-05-29): the old GET(pending)→POST approve→
4699
+ // GET(approved) three-step chain collapses to ONE step. A pending client +
4700
+ // valid session auto-approves on the first GET and lands the user directly
4701
+ // on the consent screen. The separate POST approve endpoint still exists
4702
+ // for the cross-origin SPA case (tested above) but the in-flow operator no
4703
+ // longer needs it.
4482
4704
  const { db, cleanup } = await makeDb();
4483
4705
  try {
4484
4706
  const user = await createUser(db, "owner", "pw");
@@ -4491,57 +4713,19 @@ describe("inline approve button on pending /oauth/authorize (#208)", () => {
4491
4713
  const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, SESSION_COOKIE_TTL_S)}`;
4492
4714
  const authorizeHref = pendingAuthorizeUrl(reg.client.clientId);
4493
4715
 
4494
- // Step 1: GET /oauth/authorize on a pending client renders the approve form.
4495
- const getRes = handleAuthorizeGet(
4496
- db,
4497
- new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
4498
- { issuer: ISSUER },
4499
- );
4500
- expect(getRes.status).toBe(403);
4501
- const getHtml = await getRes.text();
4502
- expect(getHtml).toContain('action="/oauth/authorize/approve"');
4503
-
4504
- // Pull the return_to value the form would submit. It's the path+search
4505
- // of the authorize URL.
4506
- const authorizeUrlParsed = new URL(authorizeHref);
4507
- const returnTo = `${authorizeUrlParsed.pathname}${authorizeUrlParsed.search}`;
4508
-
4509
- // Step 2: POST the approve form.
4510
- const postForm = new URLSearchParams({
4511
- __csrf: TEST_CSRF,
4512
- client_id: reg.client.clientId,
4513
- return_to: returnTo,
4514
- });
4515
- const postRes = await handleApproveClientPost(
4516
- db,
4517
- new Request(`${ISSUER}/oauth/authorize/approve`, {
4518
- method: "POST",
4519
- body: postForm,
4520
- headers: {
4521
- "content-type": "application/x-www-form-urlencoded",
4522
- cookie,
4523
- origin: ISSUER,
4524
- },
4525
- }),
4526
- { issuer: ISSUER },
4527
- );
4528
- expect(postRes.status).toBe(302);
4529
- expect(postRes.headers.get("location")).toBe(returnTo);
4530
-
4531
- // Step 3: GET /oauth/authorize again — now the client is approved, so
4532
- // the operator lands on the consent screen.
4533
- const reentryRes = handleAuthorizeGet(
4716
+ const res = handleAuthorizeGet(
4534
4717
  db,
4535
4718
  new Request(authorizeHref, { headers: { cookie, origin: ISSUER } }),
4536
4719
  { issuer: ISSUER, loadServicesManifest: fixtureLoadServicesManifest },
4537
4720
  );
4538
- expect(reentryRes.status).toBe(200);
4539
- const consentHtml = await reentryRes.text();
4540
- // Consent screen markers (renderConsent uses these).
4541
- // Page h1: "Approve <client>?" per design-system.md §5 (was "Authorize").
4721
+ // Consent screen, in one step — no 403 approve-pending, no POST needed.
4722
+ expect(res.status).toBe(200);
4723
+ const consentHtml = await res.text();
4542
4724
  expect(consentHtml).toContain('name="__action" value="consent"');
4543
4725
  expect(consentHtml).toContain("Approve");
4544
4726
  expect(consentHtml).toContain("RoundTrip");
4727
+ // Client auto-approved as a side effect of the consent render.
4728
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
4545
4729
  } finally {
4546
4730
  cleanup();
4547
4731
  }
@@ -6920,7 +7104,14 @@ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", (
6920
7104
  }
6921
7105
  });
6922
7106
 
6923
- test("falls through to approve-pending when requested scope is NOT covered by prior grant (superset)", async () => {
7107
+ test("scope NOT covered by prior client_name grant (superset) → single CONSENT render (single-consent change)", async () => {
7108
+ // Single-consent change (2026-05-29): the separate operator approval gate
7109
+ // is retired. A pending client + valid session auto-approves and falls
7110
+ // through. Trust-by-name carry-over only fires when the prior grant covers
7111
+ // the new scopes; here WRITE wasn't covered, so no silent carry-over — the
7112
+ // user sees ONE consent screen (200) instead of the old 403 approve-pending
7113
+ // page. The fresh client_id is now approved (the user's consent is the
7114
+ // authorization).
6924
7115
  const { db, cleanup } = await makeDb();
6925
7116
  try {
6926
7117
  const user = await createUser(db, "owner", "pw");
@@ -6959,11 +7150,12 @@ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", (
6959
7150
  issuer: ISSUER,
6960
7151
  loadServicesManifest: fixtureLoadServicesManifest,
6961
7152
  });
6962
- // Approve-pending render 403 because the new scope wasn't trusted
6963
- expect(res.status).toBe(403);
6964
- expect(await res.text()).toContain("App not yet approved");
6965
- // The fresh client_id stays pending
6966
- expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
7153
+ // Consent render (200)not the old 403 approve-pending page.
7154
+ expect(res.status).toBe(200);
7155
+ expect(res.headers.get("content-type")).toContain("text/html");
7156
+ expect(await res.text()).toContain("vault:default:write");
7157
+ // The fresh client_id is auto-approved (consent IS the authorization).
7158
+ expect(getClient(db, fresh.client.clientId)?.status).toBe("approved");
6967
7159
  } finally {
6968
7160
  cleanup();
6969
7161
  }
@@ -7009,7 +7201,11 @@ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", (
7009
7201
  }
7010
7202
  });
7011
7203
 
7012
- test("falls through when client_name is missing/empty (can't match a prior grant)", async () => {
7204
+ test("client_name missing no trust carry-over, but session still auto-approves → single CONSENT (single-consent change)", async () => {
7205
+ // Single-consent change (2026-05-29): with no client_name, the trust-by-
7206
+ // name carry-over can't match, so there's no silent skip — but a valid
7207
+ // session still auto-approves the pending client and falls through to ONE
7208
+ // consent screen (200), rather than the old 403 approve-pending page.
7013
7209
  const { db, cleanup } = await makeDb();
7014
7210
  try {
7015
7211
  const user = await createUser(db, "owner", "pw");
@@ -7046,8 +7242,10 @@ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", (
7046
7242
  issuer: ISSUER,
7047
7243
  loadServicesManifest: fixtureLoadServicesManifest,
7048
7244
  });
7049
- expect(res.status).toBe(403);
7050
- expect(getClient(db, fresh.client.clientId)?.status).toBe("pending");
7245
+ // Consent render (200), and the fresh client is auto-approved.
7246
+ expect(res.status).toBe(200);
7247
+ expect(res.headers.get("content-type")).toContain("text/html");
7248
+ expect(getClient(db, fresh.client.clientId)?.status).toBe("approved");
7051
7249
  } finally {
7052
7250
  cleanup();
7053
7251
  }
@@ -7470,3 +7668,974 @@ describe("zero-vault non-admin privesc gate (hub#429 reviewer)", () => {
7470
7668
  }
7471
7669
  });
7472
7670
  });
7671
+
7672
+ // RFC 8707 resource binding (fix #461). A friend connecting an MCP client to
7673
+ // ONE vault (`<origin>/vault/<name>/mcp`) must see ONLY that vault's scopes on
7674
+ // consent, and the minted token must carry the narrow, NAMED scope +
7675
+ // `aud=vault.<name>` — otherwise (a) the consent screen is scary (whole-hub
7676
+ // catalog) and (b) a current-line vault REJECTS the token via
7677
+ // `findBroadVaultScopes` (unnamed `vault:read` → `aud=vault` → 401).
7678
+ //
7679
+ // The deps thread `hubBoundOrigins` so the resource's origin is recognized as
7680
+ // one the hub fronts — same set the same-origin CSRF gate consults.
7681
+ describe("RFC 8707 resource binding — vault-bound MCP (fix #461)", () => {
7682
+ const RESOURCE_DEPS = {
7683
+ issuer: ISSUER,
7684
+ loadServicesManifest: () => MULTI_VAULT_MANIFEST,
7685
+ hubBoundOrigins: () => [ISSUER],
7686
+ };
7687
+
7688
+ // Two vaults on the hub so "narrow to ONE" is observable: a request bound to
7689
+ // `jon` must NOT surface `boulder`'s scopes nor the rest of the catalog.
7690
+ const MULTI_VAULT_MANIFEST: ServicesManifest = {
7691
+ services: [
7692
+ {
7693
+ name: "parachute-vault",
7694
+ port: 1940,
7695
+ paths: ["/vault/jon", "/vault/boulder"],
7696
+ health: "/health",
7697
+ version: "0.6.0",
7698
+ },
7699
+ {
7700
+ name: "parachute-scribe",
7701
+ port: 1943,
7702
+ paths: ["/scribe"],
7703
+ health: "/health",
7704
+ version: "0.6.0",
7705
+ },
7706
+ ],
7707
+ };
7708
+
7709
+ /**
7710
+ * Mirror of `parachute-vault/src/scopes.ts:findBroadVaultScopes` — the exact
7711
+ * predicate `authenticateHubJwt` runs to REJECT hub tokens. A token a
7712
+ * current-line vault accepts must (a) carry zero broad `vault:<verb>` scopes
7713
+ * and (b) name the vault in the audience. Inlined (vault is a separate
7714
+ * package, not a hub dep) so this hub test genuinely encodes vault's
7715
+ * contract — the cross-cutting half of the E2E gate.
7716
+ */
7717
+ function findBroadVaultScopes(granted: string[]): string[] {
7718
+ return granted.filter((s) => {
7719
+ const parts = s.split(":");
7720
+ return (
7721
+ parts.length === 2 &&
7722
+ parts[0] === "vault" &&
7723
+ ["read", "write", "admin"].includes(parts[1] ?? "")
7724
+ );
7725
+ });
7726
+ }
7727
+
7728
+ test("E2E GATE: DCR → /authorize?resource=…/vault/jon/mcp → consent → code → /token mints aud=vault.jon + NAMED narrow scopes that a current-line vault accepts", async () => {
7729
+ const { db, cleanup } = await makeDb();
7730
+ try {
7731
+ // --- the operator (first admin) signed into the hub ---
7732
+ const user = await createUser(db, "owner", "pw");
7733
+ const session = createSession(db, { userId: user.id });
7734
+ const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
7735
+
7736
+ // --- DCR: register the friend's MCP client (plain pending, then
7737
+ // operator-approve so consent renders rather than same-hub
7738
+ // auto-trust skipping it) ---
7739
+ const regRes = await handleRegister(
7740
+ db,
7741
+ new Request(`${ISSUER}/oauth/register`, {
7742
+ method: "POST",
7743
+ body: JSON.stringify({
7744
+ redirect_uris: ["https://claude.ai/mcp/callback"],
7745
+ client_name: "claude-code",
7746
+ scope: "vault:read vault:write",
7747
+ }),
7748
+ headers: { "content-type": "application/json" },
7749
+ }),
7750
+ RESOURCE_DEPS,
7751
+ );
7752
+ expect(regRes.status).toBe(201);
7753
+ const reg = (await regRes.json()) as { client_id: string };
7754
+ approveClient(db, reg.client_id);
7755
+
7756
+ // --- /authorize WITH the RFC 8707 resource indicator. The client asks
7757
+ // for UNNAMED vault:read/write but names the jon MCP resource. ---
7758
+ const { verifier, challenge } = makePkce();
7759
+ const authReq = new Request(
7760
+ authorizeUrl({
7761
+ client_id: reg.client_id,
7762
+ redirect_uri: "https://claude.ai/mcp/callback",
7763
+ response_type: "code",
7764
+ code_challenge: challenge,
7765
+ code_challenge_method: "S256",
7766
+ scope: "vault:read vault:write",
7767
+ resource: `${ISSUER}/vault/jon/mcp`,
7768
+ }),
7769
+ { headers: { cookie } },
7770
+ );
7771
+ const authRes = handleAuthorizeGet(db, authReq, RESOURCE_DEPS);
7772
+ expect(authRes.status).toBe(200);
7773
+ const consentHtml = await authRes.text();
7774
+
7775
+ // Consent shows ONLY jon's scopes — narrowed + locked, no whole-hub
7776
+ // catalog, no dropdown to guess, no other vault.
7777
+ expect(consentHtml).toContain("vault:jon:read");
7778
+ expect(consentHtml).toContain("vault:jon:write");
7779
+ // Scary-scope guard: the friend never sees the rest of the catalog or
7780
+ // the other vault.
7781
+ expect(consentHtml).not.toContain("vault:boulder");
7782
+ expect(consentHtml).not.toContain("hub:admin");
7783
+ expect(consentHtml).not.toContain("scribe:");
7784
+ // No vault-picker dropdown — the vault is locked to jon by the resource.
7785
+ expect(consentHtml).not.toContain('name="vault_pick"');
7786
+
7787
+ // --- consent submit (approve). The hidden inputs already carry the
7788
+ // narrowed named scopes; the POST path re-narrows defensively. ---
7789
+ const consentForm = new URLSearchParams({
7790
+ __action: "consent",
7791
+ __csrf: TEST_CSRF,
7792
+ approve: "yes",
7793
+ client_id: reg.client_id,
7794
+ redirect_uri: "https://claude.ai/mcp/callback",
7795
+ response_type: "code",
7796
+ scope: "vault:jon:read vault:jon:write",
7797
+ code_challenge: challenge,
7798
+ code_challenge_method: "S256",
7799
+ resource: `${ISSUER}/vault/jon/mcp`,
7800
+ });
7801
+ const consentRes = await handleAuthorizePost(
7802
+ db,
7803
+ new Request(`${ISSUER}/oauth/authorize`, {
7804
+ method: "POST",
7805
+ body: consentForm,
7806
+ headers: { "content-type": "application/x-www-form-urlencoded", cookie },
7807
+ }),
7808
+ RESOURCE_DEPS,
7809
+ );
7810
+ expect(consentRes.status).toBe(302);
7811
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
7812
+ expect(code).toBeTruthy();
7813
+
7814
+ // --- /token exchange ---
7815
+ const tokenRes = await handleToken(
7816
+ db,
7817
+ new Request(`${ISSUER}/oauth/token`, {
7818
+ method: "POST",
7819
+ body: new URLSearchParams({
7820
+ grant_type: "authorization_code",
7821
+ code: code ?? "",
7822
+ client_id: reg.client_id,
7823
+ redirect_uri: "https://claude.ai/mcp/callback",
7824
+ code_verifier: verifier,
7825
+ }),
7826
+ headers: { "content-type": "application/x-www-form-urlencoded" },
7827
+ }),
7828
+ RESOURCE_DEPS,
7829
+ );
7830
+ expect(tokenRes.status).toBe(200);
7831
+ const tok = (await tokenRes.json()) as { access_token: string; scope: string };
7832
+
7833
+ // Wire-level scope: NAMED + narrow — NOT the catalog, NOT unnamed.
7834
+ expect(tok.scope).toBe("vault:jon:read vault:jon:write");
7835
+
7836
+ // --- minted access token claims ---
7837
+ const { payload } = await validateAccessToken(db, tok.access_token, ISSUER);
7838
+ expect(payload.aud).toBe("vault.jon"); // resource-bound audience (RFC 8707)
7839
+ expect(payload.scope).toBe("vault:jon:read vault:jon:write");
7840
+ expect(payload.iss).toBe(ISSUER);
7841
+
7842
+ // --- CROSS-CUTTING: the token shape a current-line vault REQUIRES.
7843
+ // vault's `authenticateHubJwt` runs `findBroadVaultScopes` (reject
7844
+ // any unnamed vault verb) + audience strict-check `vault.<name>`.
7845
+ const grantedScopes = (payload.scope as string).split(" ");
7846
+ expect(findBroadVaultScopes(grantedScopes)).toEqual([]); // no broad-scope rejection
7847
+ expect(payload.aud).toBe("vault.jon"); // matches the URL-derived vault name at /vault/jon/mcp
7848
+ // Every granted scope is the named form vault accepts.
7849
+ for (const s of grantedScopes) {
7850
+ expect(s).toMatch(/^vault:jon:(read|write)$/);
7851
+ }
7852
+ } finally {
7853
+ cleanup();
7854
+ }
7855
+ });
7856
+
7857
+ test("resource → consent narrows to the named vault (no whole-hub catalog, picker locked)", async () => {
7858
+ const { db, cleanup } = await makeDb();
7859
+ try {
7860
+ const user = await createUser(db, "owner", "pw");
7861
+ const session = createSession(db, { userId: user.id });
7862
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7863
+ const { challenge } = makePkce();
7864
+ const res = handleAuthorizeGet(
7865
+ db,
7866
+ new Request(
7867
+ authorizeUrl({
7868
+ client_id: reg.client.clientId,
7869
+ redirect_uri: "https://app.example/cb",
7870
+ response_type: "code",
7871
+ code_challenge: challenge,
7872
+ code_challenge_method: "S256",
7873
+ scope: "vault:read",
7874
+ resource: `${ISSUER}/vault/jon/.well-known/oauth-protected-resource`,
7875
+ }),
7876
+ {
7877
+ headers: {
7878
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7879
+ },
7880
+ },
7881
+ ),
7882
+ RESOURCE_DEPS,
7883
+ );
7884
+ expect(res.status).toBe(200);
7885
+ const html = await res.text();
7886
+ // PRM-URL form of the resource resolves to jon too.
7887
+ expect(html).toContain("vault:jon:read");
7888
+ expect(html).not.toContain('name="vault_pick"');
7889
+ expect(html).not.toContain("vault:boulder");
7890
+ } finally {
7891
+ cleanup();
7892
+ }
7893
+ });
7894
+
7895
+ test("no resource param → behavior unchanged (unnamed scope still renders the picker)", async () => {
7896
+ const { db, cleanup } = await makeDb();
7897
+ try {
7898
+ const user = await createUser(db, "owner", "pw");
7899
+ const session = createSession(db, { userId: user.id });
7900
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7901
+ const { challenge } = makePkce();
7902
+ const res = handleAuthorizeGet(
7903
+ db,
7904
+ new Request(
7905
+ authorizeUrl({
7906
+ client_id: reg.client.clientId,
7907
+ redirect_uri: "https://app.example/cb",
7908
+ response_type: "code",
7909
+ code_challenge: challenge,
7910
+ code_challenge_method: "S256",
7911
+ scope: "vault:read",
7912
+ // no resource param
7913
+ }),
7914
+ {
7915
+ headers: {
7916
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7917
+ },
7918
+ },
7919
+ ),
7920
+ RESOURCE_DEPS,
7921
+ );
7922
+ expect(res.status).toBe(200);
7923
+ const html = await res.text();
7924
+ // Manual-pick path preserved: picker renders, vault not pre-narrowed.
7925
+ expect(html).toContain("Pick a vault");
7926
+ expect(html).toContain('name="vault_pick"');
7927
+ } finally {
7928
+ cleanup();
7929
+ }
7930
+ });
7931
+
7932
+ test("off-origin resource → ignored (no narrowing; manual-pick path)", async () => {
7933
+ const { db, cleanup } = await makeDb();
7934
+ try {
7935
+ const user = await createUser(db, "owner", "pw");
7936
+ const session = createSession(db, { userId: user.id });
7937
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7938
+ const { challenge } = makePkce();
7939
+ const res = handleAuthorizeGet(
7940
+ db,
7941
+ new Request(
7942
+ authorizeUrl({
7943
+ client_id: reg.client.clientId,
7944
+ redirect_uri: "https://app.example/cb",
7945
+ response_type: "code",
7946
+ code_challenge: challenge,
7947
+ code_challenge_method: "S256",
7948
+ scope: "vault:read",
7949
+ resource: "https://evil.example/vault/jon/mcp",
7950
+ }),
7951
+ {
7952
+ headers: {
7953
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7954
+ },
7955
+ },
7956
+ ),
7957
+ RESOURCE_DEPS,
7958
+ );
7959
+ expect(res.status).toBe(200);
7960
+ const html = await res.text();
7961
+ // An attacker-controlled resource origin can't drive narrowing — the
7962
+ // flow falls back to the normal manual picker.
7963
+ expect(html).toContain("Pick a vault");
7964
+ expect(html).not.toContain("vault:jon:read");
7965
+ } finally {
7966
+ cleanup();
7967
+ }
7968
+ });
7969
+
7970
+ test("resource-bound vault:admin → vault:jon:admin now requestable; OWNER consents (single-consent change)", async () => {
7971
+ // Single-consent change (2026-05-29): `vault:<name>:admin` is requestable
7972
+ // now. Narrowing turns the resource-bound `vault:admin` into
7973
+ // `vault:jon:admin`, which reaches the consent screen rather than being
7974
+ // refused at the non-requestable gate. The consenting user here is the
7975
+ // owner (first user = isFirstAdmin), who holds admin everywhere, so the
7976
+ // anti-privesc cap at the mint choke-point admits it. The consent screen
7977
+ // renders the narrowed admin scope with its admin badge.
7978
+ const { db, cleanup } = await makeDb();
7979
+ try {
7980
+ const user = await createUser(db, "owner", "pw");
7981
+ const session = createSession(db, { userId: user.id });
7982
+ const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
7983
+ const { challenge } = makePkce();
7984
+ const res = handleAuthorizeGet(
7985
+ db,
7986
+ new Request(
7987
+ authorizeUrl({
7988
+ client_id: reg.client.clientId,
7989
+ redirect_uri: "https://app.example/cb",
7990
+ response_type: "code",
7991
+ code_challenge: challenge,
7992
+ code_challenge_method: "S256",
7993
+ scope: "vault:admin",
7994
+ resource: `${ISSUER}/vault/jon/mcp`,
7995
+ }),
7996
+ {
7997
+ headers: {
7998
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
7999
+ },
8000
+ },
8001
+ ),
8002
+ RESOURCE_DEPS,
8003
+ );
8004
+ expect(res.status).toBe(200);
8005
+ const html = await res.text();
8006
+ // Consent renders the narrowed named admin scope with the admin badge.
8007
+ expect(html).toContain("vault:jon:admin");
8008
+ expect(html).toContain("badge-admin");
8009
+ } finally {
8010
+ cleanup();
8011
+ }
8012
+ });
8013
+ });
8014
+
8015
+ // ───────────────────────────────────────────────────────────────────────────
8016
+ // Single OAuth consent + grantable vault:<name>:admin with delegate-only-what-
8017
+ // you-hold cap (2026-05-29). Three changes land together:
8018
+ // 1. The separate operator client-approval gate is retired — a pending
8019
+ // client + valid session auto-approves and falls through to ONE consent.
8020
+ // 2. `vault:<name>:admin` is requestable via OAuth (capped at mint).
8021
+ // 3. Anti-privesc verb-cap at the SINGLE mint choke-point
8022
+ // (`issueAuthCodeRedirect`): a non-owner may only delegate vault verbs
8023
+ // they themselves hold; un-held verbs (notably admin) are DROPPED, and an
8024
+ // admin-only request from a non-owner is REFUSED (never a zero-scope mint).
8025
+ // ───────────────────────────────────────────────────────────────────────────
8026
+ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026-05-29)", () => {
8027
+ const TTL_S = Math.floor(SESSION_TTL_MS / 1000);
8028
+
8029
+ // The hub manifest must contain the vaults the assigned users target.
8030
+ const CAP_MANIFEST: ServicesManifest = {
8031
+ services: [
8032
+ {
8033
+ name: "parachute-vault",
8034
+ port: 1940,
8035
+ paths: ["/vault/work", "/vault/other"],
8036
+ health: "/health",
8037
+ version: "0.6.0",
8038
+ },
8039
+ ],
8040
+ };
8041
+ const capDeps = {
8042
+ issuer: ISSUER,
8043
+ loadServicesManifest: () => CAP_MANIFEST,
8044
+ hubBoundOrigins: () => [ISSUER],
8045
+ };
8046
+
8047
+ // Build + submit a consent form, returning the raw Response. `userIsOwner`
8048
+ // just documents intent; identity comes from the session cookie.
8049
+ async function submitConsent(
8050
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
8051
+ sessionId: string,
8052
+ clientId: string,
8053
+ scope: string,
8054
+ challenge: string,
8055
+ extra: Record<string, string> = {},
8056
+ ): Promise<Response> {
8057
+ const form = new URLSearchParams({
8058
+ __action: "consent",
8059
+ __csrf: TEST_CSRF,
8060
+ approve: "yes",
8061
+ client_id: clientId,
8062
+ redirect_uri: "https://app.example/cb",
8063
+ response_type: "code",
8064
+ scope,
8065
+ code_challenge: challenge,
8066
+ code_challenge_method: "S256",
8067
+ ...extra,
8068
+ });
8069
+ return handleAuthorizePost(
8070
+ db,
8071
+ new Request(`${ISSUER}/oauth/authorize`, {
8072
+ method: "POST",
8073
+ body: form,
8074
+ headers: {
8075
+ "content-type": "application/x-www-form-urlencoded",
8076
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, TTL_S)}`,
8077
+ },
8078
+ }),
8079
+ capDeps,
8080
+ );
8081
+ }
8082
+
8083
+ async function redeemToScopeAud(
8084
+ db: Awaited<ReturnType<typeof makeDb>>["db"],
8085
+ code: string,
8086
+ clientId: string,
8087
+ verifier: string,
8088
+ ): Promise<{ scope: string; aud: unknown }> {
8089
+ const tokenForm = new URLSearchParams({
8090
+ grant_type: "authorization_code",
8091
+ code,
8092
+ client_id: clientId,
8093
+ redirect_uri: "https://app.example/cb",
8094
+ code_verifier: verifier,
8095
+ });
8096
+ const tokenRes = await handleToken(
8097
+ db,
8098
+ new Request(`${ISSUER}/oauth/token`, {
8099
+ method: "POST",
8100
+ body: tokenForm,
8101
+ headers: { "content-type": "application/x-www-form-urlencoded" },
8102
+ }),
8103
+ capDeps,
8104
+ );
8105
+ expect(tokenRes.status).toBe(200);
8106
+ const body = (await tokenRes.json()) as { access_token: string; scope: string };
8107
+ const { payload } = await validateAccessToken(db, body.access_token, ISSUER);
8108
+ return { scope: body.scope, aud: payload.aud };
8109
+ }
8110
+
8111
+ // Test 1 — pending client + session → consent renders (200), client flipped approved.
8112
+ test("[1] pending client + session → consent renders (200) + client flipped approved", async () => {
8113
+ const { db, cleanup } = await makeDb();
8114
+ try {
8115
+ const owner = await createUser(db, "owner", "pw");
8116
+ const session = createSession(db, { userId: owner.id });
8117
+ const reg = registerClient(db, {
8118
+ redirectUris: ["https://app.example/cb"],
8119
+ clientName: "Claude",
8120
+ status: "pending",
8121
+ });
8122
+ const { challenge } = makePkce();
8123
+ const req = new Request(
8124
+ authorizeUrl({
8125
+ client_id: reg.client.clientId,
8126
+ redirect_uri: "https://app.example/cb",
8127
+ response_type: "code",
8128
+ code_challenge: challenge,
8129
+ code_challenge_method: "S256",
8130
+ scope: "vault:work:read",
8131
+ }),
8132
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8133
+ );
8134
+ const res = handleAuthorizeGet(db, req, capDeps);
8135
+ expect(res.status).toBe(200);
8136
+ expect(res.headers.get("content-type")).toContain("text/html");
8137
+ expect(await res.text()).not.toContain("App not yet approved");
8138
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
8139
+ } finally {
8140
+ cleanup();
8141
+ }
8142
+ });
8143
+
8144
+ // Test 4 — post-login round-trip: a session-less GET renders the unauth
8145
+ // pending page whose CTA points at /login?next=<authorize URL>; re-entering
8146
+ // WITH a session lands on consent.
8147
+ test("[4] post-login round-trip → unauth pending CTA, then consent after login", async () => {
8148
+ const { db, cleanup } = await makeDb();
8149
+ try {
8150
+ const owner = await createUser(db, "owner", "pw");
8151
+ const session = createSession(db, { userId: owner.id });
8152
+ const reg = registerClient(db, {
8153
+ redirectUris: ["https://app.example/cb"],
8154
+ clientName: "Claude",
8155
+ status: "pending",
8156
+ });
8157
+ const { challenge } = makePkce();
8158
+ const href = authorizeUrl({
8159
+ client_id: reg.client.clientId,
8160
+ redirect_uri: "https://app.example/cb",
8161
+ response_type: "code",
8162
+ code_challenge: challenge,
8163
+ code_challenge_method: "S256",
8164
+ scope: "vault:work:read",
8165
+ state: "rt-login",
8166
+ });
8167
+ // Session-less: unauth pending page with the login round-trip CTA.
8168
+ const unauth = handleAuthorizeGet(db, new Request(href), capDeps);
8169
+ expect(unauth.status).toBe(403);
8170
+ const unauthHtml = await unauth.text();
8171
+ expect(unauthHtml).toContain("App not yet approved");
8172
+ const u = new URL(href);
8173
+ const returnTo = `${u.pathname}${u.search}`;
8174
+ expect(unauthHtml).toContain(`/login?next=${encodeURIComponent(returnTo)}`);
8175
+ // After login (now carrying a session) → consent renders.
8176
+ const authed = handleAuthorizeGet(
8177
+ db,
8178
+ new Request(href, {
8179
+ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` },
8180
+ }),
8181
+ capDeps,
8182
+ );
8183
+ expect(authed.status).toBe(200);
8184
+ expect(getClient(db, reg.client.clientId)?.status).toBe("approved");
8185
+ } finally {
8186
+ cleanup();
8187
+ }
8188
+ });
8189
+
8190
+ // Test 6 — owner + vault:<name>:admin → consent renders, admin badge shown.
8191
+ test("[6] owner + scope=vault:work:admin → consent renders (no invalid_scope) + admin badge", async () => {
8192
+ const { db, cleanup } = await makeDb();
8193
+ try {
8194
+ const owner = await createUser(db, "owner", "pw");
8195
+ const session = createSession(db, { userId: owner.id });
8196
+ const reg = registerClient(db, {
8197
+ redirectUris: ["https://app.example/cb"],
8198
+ status: "approved",
8199
+ });
8200
+ const { challenge } = makePkce();
8201
+ const res = handleAuthorizeGet(
8202
+ db,
8203
+ new Request(
8204
+ authorizeUrl({
8205
+ client_id: reg.client.clientId,
8206
+ redirect_uri: "https://app.example/cb",
8207
+ response_type: "code",
8208
+ code_challenge: challenge,
8209
+ code_challenge_method: "S256",
8210
+ scope: "vault:work:admin",
8211
+ }),
8212
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8213
+ ),
8214
+ capDeps,
8215
+ );
8216
+ expect(res.status).toBe(200);
8217
+ const html = await res.text();
8218
+ expect(html).toContain("vault:work:admin");
8219
+ expect(html).toContain("badge-admin");
8220
+ } finally {
8221
+ cleanup();
8222
+ }
8223
+ });
8224
+
8225
+ // Test 7 — owner consents to vault:<name>:admin → token scope + aud correct.
8226
+ test("[7] owner consents to vault:work:admin → token scope=vault:work:admin, aud=vault.work", async () => {
8227
+ const { db, cleanup } = await makeDb();
8228
+ try {
8229
+ const owner = await createUser(db, "owner", "pw");
8230
+ const session = createSession(db, { userId: owner.id });
8231
+ const reg = registerClient(db, {
8232
+ redirectUris: ["https://app.example/cb"],
8233
+ status: "approved",
8234
+ });
8235
+ const { verifier, challenge } = makePkce();
8236
+ const consentRes = await submitConsent(
8237
+ db,
8238
+ session.id,
8239
+ reg.client.clientId,
8240
+ "vault:work:admin",
8241
+ challenge,
8242
+ );
8243
+ expect(consentRes.status).toBe(302);
8244
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8245
+ expect(code).toBeTruthy();
8246
+ const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8247
+ expect(scope).toBe("vault:work:admin");
8248
+ expect(aud).toBe("vault.work");
8249
+ } finally {
8250
+ cleanup();
8251
+ }
8252
+ });
8253
+
8254
+ // Test 9 — privesc: read/write assigned (non-owner) user requests
8255
+ // vault:work:admin + vault:work:write → admin DROPPED, token has write only,
8256
+ // recorded grant lacks admin.
8257
+ test("[9] non-owner read/write user requests admin+write → admin DROPPED (write only), grant lacks admin", async () => {
8258
+ const { db, cleanup } = await makeDb();
8259
+ try {
8260
+ await createUser(db, "owner", "pw"); // first user = owner; consumes the admin slot.
8261
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8262
+ setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write]
8263
+ const session = createSession(db, { userId: friend.id });
8264
+ const reg = registerClient(db, {
8265
+ redirectUris: ["https://app.example/cb"],
8266
+ status: "approved",
8267
+ });
8268
+ const { verifier, challenge } = makePkce();
8269
+ const consentRes = await submitConsent(
8270
+ db,
8271
+ session.id,
8272
+ reg.client.clientId,
8273
+ "vault:work:admin vault:work:write",
8274
+ challenge,
8275
+ );
8276
+ expect(consentRes.status).toBe(302);
8277
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8278
+ const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8279
+ // admin dropped; write kept.
8280
+ expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8281
+ expect(aud).toBe("vault.work");
8282
+ // Recorded grant lacks admin.
8283
+ const grant = findGrant(db, friend.id, reg.client.clientId);
8284
+ expect(grant?.scopes).toContain("vault:work:write");
8285
+ expect(grant?.scopes).not.toContain("vault:work:admin");
8286
+ } finally {
8287
+ cleanup();
8288
+ }
8289
+ });
8290
+
8291
+ // Test 10 — non-owner admin-ONLY request → REFUSED (clear error), no token.
8292
+ test("[10] non-owner admin-only request → REFUSED with invalid_scope, no token minted", async () => {
8293
+ const { db, cleanup } = await makeDb();
8294
+ try {
8295
+ await createUser(db, "owner", "pw");
8296
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8297
+ setUserVaults(db, friend.id, ["work"]);
8298
+ const session = createSession(db, { userId: friend.id });
8299
+ const reg = registerClient(db, {
8300
+ redirectUris: ["https://app.example/cb"],
8301
+ status: "approved",
8302
+ });
8303
+ const { challenge } = makePkce();
8304
+ const consentRes = await submitConsent(
8305
+ db,
8306
+ session.id,
8307
+ reg.client.clientId,
8308
+ "vault:work:admin",
8309
+ challenge,
8310
+ );
8311
+ // Capping leaves an EMPTY scope set → refuse (no zero-scope token).
8312
+ expect(consentRes.status).toBe(302);
8313
+ const loc = new URL(consentRes.headers.get("location") ?? "");
8314
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
8315
+ expect(loc.searchParams.get("error")).toBe("invalid_scope");
8316
+ expect(loc.searchParams.get("code")).toBeNull();
8317
+ // No grant recorded.
8318
+ expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
8319
+ } finally {
8320
+ cleanup();
8321
+ }
8322
+ });
8323
+
8324
+ // Test 11 — non-owner unnamed vault:admin + picks assigned vault → after
8325
+ // narrowing, admin dropped (cap runs post-narrow).
8326
+ test("[11] non-owner unnamed vault:admin + picks assigned vault → admin dropped post-narrow", async () => {
8327
+ const { db, cleanup } = await makeDb();
8328
+ try {
8329
+ await createUser(db, "owner", "pw");
8330
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8331
+ setUserVaults(db, friend.id, ["work"]);
8332
+ const session = createSession(db, { userId: friend.id });
8333
+ const reg = registerClient(db, {
8334
+ redirectUris: ["https://app.example/cb"],
8335
+ status: "approved",
8336
+ });
8337
+ const { verifier, challenge } = makePkce();
8338
+ // Unnamed vault:admin + vault:write, picker resolves to "work".
8339
+ const consentRes = await submitConsent(
8340
+ db,
8341
+ session.id,
8342
+ reg.client.clientId,
8343
+ "vault:admin vault:write",
8344
+ challenge,
8345
+ { vault_pick: "work" },
8346
+ );
8347
+ // narrowVaultScopes → vault:work:admin + vault:work:write; cap drops
8348
+ // admin (non-owner doesn't hold it), keeps write → mints write only.
8349
+ expect(consentRes.status).toBe(302);
8350
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8351
+ expect(code).toBeTruthy();
8352
+ const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8353
+ expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8354
+ const grant = findGrant(db, friend.id, reg.client.clientId);
8355
+ expect(grant?.scopes).toContain("vault:work:write");
8356
+ expect(grant?.scopes).not.toContain("vault:work:admin");
8357
+ } finally {
8358
+ cleanup();
8359
+ }
8360
+ });
8361
+
8362
+ // Test 12 — owner same request as test 9 → admin GRANTED (contrast).
8363
+ test("[12] owner requests admin+write → admin GRANTED (owner bypasses the cap)", async () => {
8364
+ const { db, cleanup } = await makeDb();
8365
+ try {
8366
+ const owner = await createUser(db, "owner", "pw");
8367
+ const session = createSession(db, { userId: owner.id });
8368
+ const reg = registerClient(db, {
8369
+ redirectUris: ["https://app.example/cb"],
8370
+ status: "approved",
8371
+ });
8372
+ const { verifier, challenge } = makePkce();
8373
+ const consentRes = await submitConsent(
8374
+ db,
8375
+ session.id,
8376
+ reg.client.clientId,
8377
+ "vault:work:admin vault:work:write",
8378
+ challenge,
8379
+ );
8380
+ expect(consentRes.status).toBe(302);
8381
+ const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8382
+ const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8383
+ expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
8384
+ const grant = findGrant(db, owner.id, reg.client.clientId);
8385
+ expect(grant?.scopes).toContain("vault:work:admin");
8386
+ } finally {
8387
+ cleanup();
8388
+ }
8389
+ });
8390
+
8391
+ // Test 13 — same-hub client + session + vault:<name>:admin → does NOT
8392
+ // silently mint; consent renders (relies on scopeIsAdmin recognizing the
8393
+ // named admin form).
8394
+ test("[13] same-hub client + vault:work:admin → consent renders (not silent-mint)", async () => {
8395
+ const { db, cleanup } = await makeDb();
8396
+ try {
8397
+ const owner = await createUser(db, "owner", "pw");
8398
+ const session = createSession(db, { userId: owner.id });
8399
+ const reg = registerClient(db, {
8400
+ redirectUris: ["https://app.example/cb"],
8401
+ status: "approved",
8402
+ sameHub: true,
8403
+ });
8404
+ const { challenge } = makePkce();
8405
+ const res = handleAuthorizeGet(
8406
+ db,
8407
+ new Request(
8408
+ authorizeUrl({
8409
+ client_id: reg.client.clientId,
8410
+ redirect_uri: "https://app.example/cb",
8411
+ response_type: "code",
8412
+ code_challenge: challenge,
8413
+ code_challenge_method: "S256",
8414
+ scope: "vault:work:admin",
8415
+ }),
8416
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8417
+ ),
8418
+ capDeps,
8419
+ );
8420
+ // Consent (200), NOT a silent 302 redirect with code.
8421
+ expect(res.status).toBe(200);
8422
+ expect(res.headers.get("content-type")).toContain("text/html");
8423
+ // No grant auto-recorded (the same-hub gate did not fire).
8424
+ expect(findGrant(db, owner.id, reg.client.clientId)).toBeNull();
8425
+ } finally {
8426
+ cleanup();
8427
+ }
8428
+ });
8429
+
8430
+ // Test 14 — trust-by-name: a prior NON-admin same-name grant + a new request
8431
+ // that ADDS vault:<name>:admin → does NOT auto-promote; consent renders.
8432
+ test("[14] trust-by-name + new admin scope → no auto-promote, consent renders", async () => {
8433
+ const { db, cleanup } = await makeDb();
8434
+ try {
8435
+ const owner = await createUser(db, "owner", "pw");
8436
+ const session = createSession(db, { userId: owner.id });
8437
+ const prior = registerClient(db, {
8438
+ redirectUris: ["https://app.example/cb"],
8439
+ status: "approved",
8440
+ clientName: "Claude",
8441
+ });
8442
+ // Prior NON-admin grant under the same client_name.
8443
+ recordGrant(db, owner.id, prior.client.clientId, ["vault:work:read", "vault:work:admin"]);
8444
+ const fresh = registerClient(db, {
8445
+ redirectUris: ["https://app.example/cb"],
8446
+ status: "pending",
8447
+ clientName: "Claude",
8448
+ });
8449
+ const { challenge } = makePkce();
8450
+ // New request includes admin — even though a prior same-name grant
8451
+ // happens to list it, the trust gate excludes admin (scopeIsAdmin), so
8452
+ // it must not silently carry over.
8453
+ const res = handleAuthorizeGet(
8454
+ db,
8455
+ new Request(
8456
+ authorizeUrl({
8457
+ client_id: fresh.client.clientId,
8458
+ redirect_uri: "https://app.example/cb",
8459
+ response_type: "code",
8460
+ code_challenge: challenge,
8461
+ code_challenge_method: "S256",
8462
+ scope: "vault:work:read vault:work:admin",
8463
+ }),
8464
+ {
8465
+ headers: {
8466
+ cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}`,
8467
+ origin: ISSUER,
8468
+ },
8469
+ },
8470
+ ),
8471
+ capDeps,
8472
+ );
8473
+ // Consent (200), not a silent 302 redirect.
8474
+ expect(res.status).toBe(200);
8475
+ expect(res.headers.get("content-type")).toContain("text/html");
8476
+ } finally {
8477
+ cleanup();
8478
+ }
8479
+ });
8480
+
8481
+ // Test 15 — bypass-proof: a non-owner with NO prior admin grant cannot reach
8482
+ // issueAuthCodeRedirect with an admin scope via ANY path (skip-consent /
8483
+ // same-hub / consent). Assert no grants row ever contains an un-held admin
8484
+ // verb across each path.
8485
+ test("[15] bypass-proof: no mint path ever records an un-held admin verb for a non-owner", async () => {
8486
+ const { db, cleanup } = await makeDb();
8487
+ try {
8488
+ await createUser(db, "owner", "pw");
8489
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8490
+ setUserVaults(db, friend.id, ["work"]); // verbs [read, write] only
8491
+ const session = createSession(db, { userId: friend.id });
8492
+
8493
+ // Path A — consent-submit with admin+write → admin dropped, recorded grant
8494
+ // lacks admin.
8495
+ const regConsent = registerClient(db, {
8496
+ redirectUris: ["https://app.example/cb"],
8497
+ status: "approved",
8498
+ });
8499
+ {
8500
+ const { challenge } = makePkce();
8501
+ await submitConsent(
8502
+ db,
8503
+ session.id,
8504
+ regConsent.client.clientId,
8505
+ "vault:work:admin vault:work:write",
8506
+ challenge,
8507
+ );
8508
+ const g = findGrant(db, friend.id, regConsent.client.clientId);
8509
+ expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8510
+ }
8511
+
8512
+ // Path B — same-hub client requesting admin → consent renders (admin gate
8513
+ // blocks the silent path); no grant recorded with admin.
8514
+ const regSameHub = registerClient(db, {
8515
+ redirectUris: ["https://app.example/cb"],
8516
+ status: "approved",
8517
+ sameHub: true,
8518
+ });
8519
+ {
8520
+ const { challenge } = makePkce();
8521
+ const res = handleAuthorizeGet(
8522
+ db,
8523
+ new Request(
8524
+ authorizeUrl({
8525
+ client_id: regSameHub.client.clientId,
8526
+ redirect_uri: "https://app.example/cb",
8527
+ response_type: "code",
8528
+ code_challenge: challenge,
8529
+ code_challenge_method: "S256",
8530
+ scope: "vault:work:admin",
8531
+ }),
8532
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8533
+ ),
8534
+ capDeps,
8535
+ );
8536
+ expect(res.status).toBe(200); // consent, not silent-mint
8537
+ const g = findGrant(db, friend.id, regSameHub.client.clientId);
8538
+ expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8539
+ }
8540
+
8541
+ // Path C — skip-consent: even if a grant row were somehow seeded with an
8542
+ // admin verb, the cap at issueAuthCodeRedirect drops it before minting AND
8543
+ // re-records the capped set. Seed a poisoned grant, drive skip-consent,
8544
+ // and assert the minted token + the (re-recorded) grant carry no admin.
8545
+ const regSkip = registerClient(db, {
8546
+ redirectUris: ["https://app.example/cb"],
8547
+ status: "approved",
8548
+ });
8549
+ recordGrant(db, friend.id, regSkip.client.clientId, ["vault:work:write", "vault:work:admin"]);
8550
+ {
8551
+ const { verifier, challenge } = makePkce();
8552
+ // Request only the held write scope so skip-consent fires (covered by
8553
+ // the seeded grant) and routes through issueAuthCodeRedirect.
8554
+ const res = handleAuthorizeGet(
8555
+ db,
8556
+ new Request(
8557
+ authorizeUrl({
8558
+ client_id: regSkip.client.clientId,
8559
+ redirect_uri: "https://app.example/cb",
8560
+ response_type: "code",
8561
+ code_challenge: challenge,
8562
+ code_challenge_method: "S256",
8563
+ scope: "vault:work:write",
8564
+ }),
8565
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8566
+ ),
8567
+ capDeps,
8568
+ );
8569
+ expect(res.status).toBe(302); // skip-consent silent mint of the held scope
8570
+ const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
8571
+ const { scope } = await redeemToScopeAud(db, code ?? "", regSkip.client.clientId, verifier);
8572
+ expect(scope.split(" ")).not.toContain("vault:work:admin");
8573
+ }
8574
+ } finally {
8575
+ cleanup();
8576
+ }
8577
+ });
8578
+
8579
+ // Reviewer fold (security-relevant): test 15 path C only requested `write`
8580
+ // against the poisoned client, which proves the cap doesn't *re-record* an
8581
+ // un-held verb. This case requests `vault:work:admin` DIRECTLY against a
8582
+ // client whose grant row ALREADY lists `vault:work:admin` (poisoned). The
8583
+ // skip-consent gate fires (the requested admin scope IS covered by the
8584
+ // poisoned grant) and routes through issueAuthCodeRedirect — where the CAP,
8585
+ // not the grant lookup, drops the admin verb. Admin-only request → caps to
8586
+ // EMPTY → invalid_scope refusal, no code, no token. This pins that the
8587
+ // cap-before-issueAuthCode invariant holds even when a stale admin grant
8588
+ // would otherwise satisfy the coverage check.
8589
+ test("[15b] non-owner requests admin DIRECTLY against a POISONED-grant client → cap refuses, no admin token", async () => {
8590
+ const { db, cleanup } = await makeDb();
8591
+ try {
8592
+ await createUser(db, "owner", "pw");
8593
+ const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8594
+ setUserVaults(db, friend.id, ["work"]); // verbs [read, write] only — never admin
8595
+ const session = createSession(db, { userId: friend.id });
8596
+ const reg = registerClient(db, {
8597
+ redirectUris: ["https://app.example/cb"],
8598
+ status: "approved",
8599
+ });
8600
+ // Poisoned grant: already lists vault:work:admin (so the skip-consent
8601
+ // coverage check would pass for a direct admin request).
8602
+ recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:work:admin"]);
8603
+
8604
+ const { challenge } = makePkce();
8605
+ const res = handleAuthorizeGet(
8606
+ db,
8607
+ new Request(
8608
+ authorizeUrl({
8609
+ client_id: reg.client.clientId,
8610
+ redirect_uri: "https://app.example/cb",
8611
+ response_type: "code",
8612
+ code_challenge: challenge,
8613
+ code_challenge_method: "S256",
8614
+ scope: "vault:work:admin",
8615
+ state: "poisoned-direct",
8616
+ }),
8617
+ { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8618
+ ),
8619
+ capDeps,
8620
+ );
8621
+ // The cap leaves an EMPTY set (non-owner doesn't hold admin) → refuse.
8622
+ // 302 to redirect_uri with invalid_scope and NO code — no token minted.
8623
+ expect(res.status).toBe(302);
8624
+ const loc = new URL(res.headers.get("location") ?? "");
8625
+ expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
8626
+ expect(loc.searchParams.get("error")).toBe("invalid_scope");
8627
+ expect(loc.searchParams.get("code")).toBeNull();
8628
+ expect(loc.searchParams.get("state")).toBe("poisoned-direct");
8629
+
8630
+ // The re-record with the capped (empty) set never happens on the refuse
8631
+ // path, so the poisoned grant is untouched — but crucially, NO mint
8632
+ // occurred. Verify no auth code row was issued for this client.
8633
+ const codeRows = db
8634
+ .query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM auth_codes WHERE client_id = ?")
8635
+ .get(reg.client.clientId);
8636
+ expect(codeRows?.n ?? 0).toBe(0);
8637
+ } finally {
8638
+ cleanup();
8639
+ }
8640
+ });
8641
+ });