@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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 {
|
|
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
|
|
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, {
|
|
4094
|
-
|
|
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("
|
|
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
|
-
|
|
4111
|
-
expect(html).toContain("
|
|
4112
|
-
|
|
4113
|
-
//
|
|
4114
|
-
expect(
|
|
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("
|
|
4479
|
-
//
|
|
4480
|
-
//
|
|
4481
|
-
//
|
|
4697
|
+
test("single-consent: GET (pending) + session → auto-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
|
-
|
|
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
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
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("
|
|
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
|
-
//
|
|
6963
|
-
expect(res.status).toBe(
|
|
6964
|
-
expect(
|
|
6965
|
-
|
|
6966
|
-
|
|
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("
|
|
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
|
-
|
|
7050
|
-
expect(
|
|
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
|
+
});
|