@openparachute/hub 0.5.13 → 0.5.14-rc.10
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 +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- 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 +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- 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 +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- 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 +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- 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 +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -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-Dzrbe6EP.js +0 -61
|
@@ -3,8 +3,9 @@ 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 { approveClient, getClient, registerClient } from "../clients.ts";
|
|
7
7
|
import { CSRF_COOKIE_NAME } from "../csrf.ts";
|
|
8
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
8
9
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
9
10
|
import {
|
|
10
11
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
setSetting,
|
|
14
15
|
} from "../hub-settings.ts";
|
|
15
16
|
import { findTokenRowByJti, validateAccessToken } from "../jwt-sign.ts";
|
|
16
|
-
import { findGrant, recordGrant } from "../grants.ts";
|
|
17
17
|
import {
|
|
18
18
|
authorizationServerMetadata,
|
|
19
19
|
buildServicesCatalog,
|
|
@@ -24,10 +24,11 @@ import {
|
|
|
24
24
|
handleRevoke,
|
|
25
25
|
handleToken,
|
|
26
26
|
protectedResourceMetadata,
|
|
27
|
+
vaultScopeForUser,
|
|
27
28
|
} from "../oauth-handlers.ts";
|
|
28
29
|
import type { ServicesManifest } from "../services-manifest.ts";
|
|
29
30
|
import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
|
|
30
|
-
import { createUser } from "../users.ts";
|
|
31
|
+
import { createUser, setUserVaults } from "../users.ts";
|
|
31
32
|
|
|
32
33
|
const ISSUER = "https://hub.example";
|
|
33
34
|
const TEST_CSRF = "csrf-test-token";
|
|
@@ -4880,6 +4881,66 @@ describe("DCR first-client auto-approve window (hub#268 Item 3)", () => {
|
|
|
4880
4881
|
// non-admin users (with `assigned_vault` non-null) see the consent picker
|
|
4881
4882
|
// locked, and the OAuth issuer mints tokens carrying `vault_scope: [<assigned>]`.
|
|
4882
4883
|
// Server-side defense refuses any mint whose picked vault disagrees.
|
|
4884
|
+
describe("vaultScopeForUser (multi-user Phase 2 PR 2 — many-to-many)", () => {
|
|
4885
|
+
test("first admin returns [] regardless of any user_vaults rows", async () => {
|
|
4886
|
+
const { db, cleanup } = await makeDb();
|
|
4887
|
+
try {
|
|
4888
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4889
|
+
expect(vaultScopeForUser(db, admin.id)).toEqual([]);
|
|
4890
|
+
} finally {
|
|
4891
|
+
cleanup();
|
|
4892
|
+
}
|
|
4893
|
+
});
|
|
4894
|
+
|
|
4895
|
+
test("non-admin with zero vault assignments returns []", async () => {
|
|
4896
|
+
const { db, cleanup } = await makeDb();
|
|
4897
|
+
try {
|
|
4898
|
+
await createUser(db, "admin-aaron", "pw");
|
|
4899
|
+
const bob = await createUser(db, "bob", "pw", { allowMulti: true });
|
|
4900
|
+
expect(vaultScopeForUser(db, bob.id)).toEqual([]);
|
|
4901
|
+
} finally {
|
|
4902
|
+
cleanup();
|
|
4903
|
+
}
|
|
4904
|
+
});
|
|
4905
|
+
|
|
4906
|
+
test("non-admin with one assigned vault returns [name]", async () => {
|
|
4907
|
+
const { db, cleanup } = await makeDb();
|
|
4908
|
+
try {
|
|
4909
|
+
await createUser(db, "admin-aaron", "pw");
|
|
4910
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
4911
|
+
allowMulti: true,
|
|
4912
|
+
assignedVaults: ["default"],
|
|
4913
|
+
});
|
|
4914
|
+
expect(vaultScopeForUser(db, bob.id)).toEqual(["default"]);
|
|
4915
|
+
} finally {
|
|
4916
|
+
cleanup();
|
|
4917
|
+
}
|
|
4918
|
+
});
|
|
4919
|
+
|
|
4920
|
+
test("non-admin with multiple assigned vaults returns each name", async () => {
|
|
4921
|
+
const { db, cleanup } = await makeDb();
|
|
4922
|
+
try {
|
|
4923
|
+
await createUser(db, "admin-aaron", "pw");
|
|
4924
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
4925
|
+
allowMulti: true,
|
|
4926
|
+
assignedVaults: ["personal", "family"],
|
|
4927
|
+
});
|
|
4928
|
+
expect(new Set(vaultScopeForUser(db, bob.id))).toEqual(new Set(["personal", "family"]));
|
|
4929
|
+
} finally {
|
|
4930
|
+
cleanup();
|
|
4931
|
+
}
|
|
4932
|
+
});
|
|
4933
|
+
|
|
4934
|
+
test("unknown user id returns [] defensively", async () => {
|
|
4935
|
+
const { db, cleanup } = await makeDb();
|
|
4936
|
+
try {
|
|
4937
|
+
expect(vaultScopeForUser(db, "no-such-id")).toEqual([]);
|
|
4938
|
+
} finally {
|
|
4939
|
+
cleanup();
|
|
4940
|
+
}
|
|
4941
|
+
});
|
|
4942
|
+
});
|
|
4943
|
+
|
|
4883
4944
|
describe("handleAuthorizeGet — multi-user assigned vault picker lock (PR 4)", () => {
|
|
4884
4945
|
test("admin user (assigned_vault null) sees the free dropdown", async () => {
|
|
4885
4946
|
const { db, cleanup } = await makeDb();
|
|
@@ -4918,13 +4979,131 @@ describe("handleAuthorizeGet — multi-user assigned vault picker lock (PR 4)",
|
|
|
4918
4979
|
}
|
|
4919
4980
|
});
|
|
4920
4981
|
|
|
4982
|
+
test("non-admin user with 2+ assigned vaults sees a free dropdown filtered to those (Phase 2 PR 2)", async () => {
|
|
4983
|
+
const { db, cleanup } = await makeDb();
|
|
4984
|
+
try {
|
|
4985
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4986
|
+
void admin;
|
|
4987
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
4988
|
+
allowMulti: true,
|
|
4989
|
+
// Only "default" is in the fixture manifest; non-admins with a
|
|
4990
|
+
// mix of valid + invalid vaults effectively get the intersection.
|
|
4991
|
+
assignedVaults: ["default", "personal"],
|
|
4992
|
+
});
|
|
4993
|
+
// Add a second vault to the manifest so the dropdown has two
|
|
4994
|
+
// valid choices to filter to.
|
|
4995
|
+
const multiVaultManifest: ServicesManifest = {
|
|
4996
|
+
services: [
|
|
4997
|
+
{
|
|
4998
|
+
name: "parachute-vault",
|
|
4999
|
+
port: 1940,
|
|
5000
|
+
paths: ["/vault/default", "/vault/personal", "/vault/family"],
|
|
5001
|
+
health: "/health",
|
|
5002
|
+
version: "0.3.0",
|
|
5003
|
+
},
|
|
5004
|
+
],
|
|
5005
|
+
};
|
|
5006
|
+
const session = createSession(db, { userId: bob.id });
|
|
5007
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5008
|
+
const { challenge } = makePkce();
|
|
5009
|
+
const req = new Request(
|
|
5010
|
+
authorizeUrl({
|
|
5011
|
+
client_id: reg.client.clientId,
|
|
5012
|
+
redirect_uri: "https://app.example/cb",
|
|
5013
|
+
response_type: "code",
|
|
5014
|
+
code_challenge: challenge,
|
|
5015
|
+
code_challenge_method: "S256",
|
|
5016
|
+
scope: "vault:read",
|
|
5017
|
+
}),
|
|
5018
|
+
{
|
|
5019
|
+
headers: {
|
|
5020
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
5021
|
+
},
|
|
5022
|
+
},
|
|
5023
|
+
);
|
|
5024
|
+
const res = handleAuthorizeGet(db, req, {
|
|
5025
|
+
issuer: ISSUER,
|
|
5026
|
+
loadServicesManifest: () => multiVaultManifest,
|
|
5027
|
+
});
|
|
5028
|
+
expect(res.status).toBe(200);
|
|
5029
|
+
const html = await res.text();
|
|
5030
|
+
// NOT the locked picker section — two vaults is a dropdown.
|
|
5031
|
+
expect(html).not.toContain('class="vault-picker vault-picker-locked"');
|
|
5032
|
+
// No locked-vault hidden input.
|
|
5033
|
+
expect(html).not.toContain('<input type="hidden" name="vault_pick"');
|
|
5034
|
+
// Two radios — for the two assigned vaults, in order.
|
|
5035
|
+
expect(html).toContain('name="vault_pick" value="default"');
|
|
5036
|
+
expect(html).toContain('name="vault_pick" value="personal"');
|
|
5037
|
+
// NOT the third hub-wide vault (`family`) — filtered out.
|
|
5038
|
+
expect(html).not.toContain('name="vault_pick" value="family"');
|
|
5039
|
+
} finally {
|
|
5040
|
+
cleanup();
|
|
5041
|
+
}
|
|
5042
|
+
});
|
|
5043
|
+
|
|
5044
|
+
test("non-admin requesting a named vault outside their list → 400 vault_scope_mismatch (Phase 2 PR 2)", async () => {
|
|
5045
|
+
const { db, cleanup } = await makeDb();
|
|
5046
|
+
try {
|
|
5047
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5048
|
+
void admin;
|
|
5049
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5050
|
+
allowMulti: true,
|
|
5051
|
+
assignedVaults: ["personal", "family"],
|
|
5052
|
+
});
|
|
5053
|
+
const multiVaultManifest: ServicesManifest = {
|
|
5054
|
+
services: [
|
|
5055
|
+
{
|
|
5056
|
+
name: "parachute-vault",
|
|
5057
|
+
port: 1940,
|
|
5058
|
+
paths: ["/vault/personal", "/vault/family", "/vault/work"],
|
|
5059
|
+
health: "/health",
|
|
5060
|
+
version: "0.3.0",
|
|
5061
|
+
},
|
|
5062
|
+
],
|
|
5063
|
+
};
|
|
5064
|
+
const session = createSession(db, { userId: bob.id });
|
|
5065
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5066
|
+
const { challenge } = makePkce();
|
|
5067
|
+
const consentForm = new URLSearchParams({
|
|
5068
|
+
__action: "consent",
|
|
5069
|
+
__csrf: TEST_CSRF,
|
|
5070
|
+
approve: "yes",
|
|
5071
|
+
client_id: reg.client.clientId,
|
|
5072
|
+
redirect_uri: "https://app.example/cb",
|
|
5073
|
+
response_type: "code",
|
|
5074
|
+
// Asking for "work" which exists on the hub but is NOT in
|
|
5075
|
+
// bob's assigned list.
|
|
5076
|
+
scope: "vault:work:read",
|
|
5077
|
+
code_challenge: challenge,
|
|
5078
|
+
code_challenge_method: "S256",
|
|
5079
|
+
});
|
|
5080
|
+
const consentRes = await handleAuthorizePost(
|
|
5081
|
+
db,
|
|
5082
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
5083
|
+
method: "POST",
|
|
5084
|
+
body: consentForm,
|
|
5085
|
+
headers: {
|
|
5086
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
5087
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
5088
|
+
},
|
|
5089
|
+
}),
|
|
5090
|
+
{ issuer: ISSUER, loadServicesManifest: () => multiVaultManifest },
|
|
5091
|
+
);
|
|
5092
|
+
expect(consentRes.status).toBe(400);
|
|
5093
|
+
const body = await consentRes.text();
|
|
5094
|
+
expect(body).toContain("vault_scope_mismatch");
|
|
5095
|
+
} finally {
|
|
5096
|
+
cleanup();
|
|
5097
|
+
}
|
|
5098
|
+
});
|
|
5099
|
+
|
|
4921
5100
|
test("non-admin user (assigned_vault set) sees the locked picker with admin-managed note", async () => {
|
|
4922
5101
|
const { db, cleanup } = await makeDb();
|
|
4923
5102
|
try {
|
|
4924
5103
|
const admin = await createUser(db, "admin-aaron", "pw");
|
|
4925
5104
|
const bob = await createUser(db, "bob", "pw", {
|
|
4926
5105
|
allowMulti: true,
|
|
4927
|
-
|
|
5106
|
+
assignedVaults: ["default"],
|
|
4928
5107
|
});
|
|
4929
5108
|
void admin;
|
|
4930
5109
|
const session = createSession(db, { userId: bob.id });
|
|
@@ -4974,7 +5153,12 @@ describe("handleAuthorizeGet — resolved scope display (approval-UX rc.19)", ()
|
|
|
4974
5153
|
test("non-admin user (assigned_vault set) sees vault:<assigned>:read on consent, not raw vault:read", async () => {
|
|
4975
5154
|
const { db, cleanup } = await makeDb();
|
|
4976
5155
|
try {
|
|
4977
|
-
const
|
|
5156
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5157
|
+
void admin;
|
|
5158
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5159
|
+
allowMulti: true,
|
|
5160
|
+
assignedVaults: ["default"],
|
|
5161
|
+
});
|
|
4978
5162
|
const session = createSession(db, { userId: bob.id });
|
|
4979
5163
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
4980
5164
|
const { challenge } = makePkce();
|
|
@@ -5050,7 +5234,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5050
5234
|
test("non-admin happy path: token carries vault_scope=[assigned] and narrowed scope", async () => {
|
|
5051
5235
|
const { db, cleanup } = await makeDb();
|
|
5052
5236
|
try {
|
|
5053
|
-
const
|
|
5237
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5238
|
+
void admin;
|
|
5239
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5240
|
+
allowMulti: true,
|
|
5241
|
+
assignedVaults: ["default"],
|
|
5242
|
+
});
|
|
5054
5243
|
const session = createSession(db, { userId: bob.id });
|
|
5055
5244
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5056
5245
|
const { verifier, challenge } = makePkce();
|
|
@@ -5164,7 +5353,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5164
5353
|
test("non-admin with disagreeing vault_pick → 400 vault_scope_mismatch", async () => {
|
|
5165
5354
|
const { db, cleanup } = await makeDb();
|
|
5166
5355
|
try {
|
|
5167
|
-
const
|
|
5356
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5357
|
+
void admin;
|
|
5358
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5359
|
+
allowMulti: true,
|
|
5360
|
+
assignedVaults: ["default"],
|
|
5361
|
+
});
|
|
5168
5362
|
const session = createSession(db, { userId: bob.id });
|
|
5169
5363
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5170
5364
|
const { challenge } = makePkce();
|
|
@@ -5223,7 +5417,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5223
5417
|
test("non-admin requesting named scope for the wrong vault → 400 vault_scope_mismatch", async () => {
|
|
5224
5418
|
const { db, cleanup } = await makeDb();
|
|
5225
5419
|
try {
|
|
5226
|
-
const
|
|
5420
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5421
|
+
void admin;
|
|
5422
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5423
|
+
allowMulti: true,
|
|
5424
|
+
assignedVaults: ["default"],
|
|
5425
|
+
});
|
|
5227
5426
|
const session = createSession(db, { userId: bob.id });
|
|
5228
5427
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5229
5428
|
const { challenge } = makePkce();
|
|
@@ -5263,7 +5462,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5263
5462
|
test("non-admin requesting named scope for the assigned vault → happy path", async () => {
|
|
5264
5463
|
const { db, cleanup } = await makeDb();
|
|
5265
5464
|
try {
|
|
5266
|
-
const
|
|
5465
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5466
|
+
void admin;
|
|
5467
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5468
|
+
allowMulti: true,
|
|
5469
|
+
assignedVaults: ["default"],
|
|
5470
|
+
});
|
|
5267
5471
|
const session = createSession(db, { userId: bob.id });
|
|
5268
5472
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5269
5473
|
const { verifier, challenge } = makePkce();
|
|
@@ -5320,7 +5524,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5320
5524
|
test("refresh flow re-derives vault_scope from current assigned_vault", async () => {
|
|
5321
5525
|
const { db, cleanup } = await makeDb();
|
|
5322
5526
|
try {
|
|
5323
|
-
const
|
|
5527
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5528
|
+
void admin;
|
|
5529
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5530
|
+
allowMulti: true,
|
|
5531
|
+
assignedVaults: ["default"],
|
|
5532
|
+
});
|
|
5324
5533
|
const session = createSession(db, { userId: bob.id });
|
|
5325
5534
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5326
5535
|
const { verifier, challenge } = makePkce();
|
|
@@ -5407,7 +5616,12 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5407
5616
|
test("refresh flow picks up a mid-session assigned_vault change", async () => {
|
|
5408
5617
|
const { db, cleanup } = await makeDb();
|
|
5409
5618
|
try {
|
|
5410
|
-
const
|
|
5619
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
5620
|
+
void admin;
|
|
5621
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
5622
|
+
allowMulti: true,
|
|
5623
|
+
assignedVaults: ["vault-a"],
|
|
5624
|
+
});
|
|
5411
5625
|
const session = createSession(db, { userId: bob.id });
|
|
5412
5626
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5413
5627
|
const { verifier, challenge } = makePkce();
|
|
@@ -5477,12 +5691,15 @@ describe("handleAuthorizePost — multi-user assigned vault defense (PR 4)", ()
|
|
|
5477
5691
|
expect(initial.payload.vault_scope).toEqual(["vault-a"]);
|
|
5478
5692
|
expect(initial.payload.scope).toBe("vault:vault-a:read");
|
|
5479
5693
|
|
|
5480
|
-
// Step 2: admin updates bob's
|
|
5481
|
-
//
|
|
5482
|
-
//
|
|
5483
|
-
// (`vaultScopeForUser`), so the next refresh
|
|
5484
|
-
// value.
|
|
5485
|
-
db.prepare("
|
|
5694
|
+
// Step 2: admin updates bob's vault assignments to ["vault-b"].
|
|
5695
|
+
// Direct DB writes against user_vaults — same effect as the PATCH
|
|
5696
|
+
// /api/users/:id/vaults endpoint. The refresh path reads the live
|
|
5697
|
+
// row at mint time (`vaultScopeForUser`), so the next refresh
|
|
5698
|
+
// should pick up the new value.
|
|
5699
|
+
db.prepare("DELETE FROM user_vaults WHERE user_id = ?").run(bob.id);
|
|
5700
|
+
db.prepare(
|
|
5701
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'write', ?)",
|
|
5702
|
+
).run(bob.id, "vault-b", new Date().toISOString());
|
|
5486
5703
|
|
|
5487
5704
|
// Step 3: refresh the token. vault_scope should be ["vault-b"] (the
|
|
5488
5705
|
// new live value); the `scope` claim stays narrowed to the original
|
|
@@ -5982,7 +6199,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
|
|
|
5982
6199
|
test("unnamed vault scope + stale assignment → banner + no-vaults picker + disabled Approve", async () => {
|
|
5983
6200
|
const { db, cleanup } = await makeDb();
|
|
5984
6201
|
try {
|
|
5985
|
-
const
|
|
6202
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6203
|
+
void admin;
|
|
6204
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6205
|
+
allowMulti: true,
|
|
6206
|
+
assignedVaults: ["default"],
|
|
6207
|
+
});
|
|
5986
6208
|
const session = createSession(db, { userId: bob.id });
|
|
5987
6209
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
5988
6210
|
const { challenge } = makePkce();
|
|
@@ -6026,7 +6248,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
|
|
|
6026
6248
|
test("named vault scope targeting stale assignment → banner + disabled Approve", async () => {
|
|
6027
6249
|
const { db, cleanup } = await makeDb();
|
|
6028
6250
|
try {
|
|
6029
|
-
const
|
|
6251
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6252
|
+
void admin;
|
|
6253
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6254
|
+
allowMulti: true,
|
|
6255
|
+
assignedVaults: ["default"],
|
|
6256
|
+
});
|
|
6030
6257
|
const session = createSession(db, { userId: bob.id });
|
|
6031
6258
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6032
6259
|
const { challenge } = makePkce();
|
|
@@ -6065,7 +6292,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
|
|
|
6065
6292
|
test("non-vault scope + stale assignment → informational banner + Approve stays enabled", async () => {
|
|
6066
6293
|
const { db, cleanup } = await makeDb();
|
|
6067
6294
|
try {
|
|
6068
|
-
const
|
|
6295
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6296
|
+
void admin;
|
|
6297
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6298
|
+
allowMulti: true,
|
|
6299
|
+
assignedVaults: ["default"],
|
|
6300
|
+
});
|
|
6069
6301
|
const session = createSession(db, { userId: bob.id });
|
|
6070
6302
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6071
6303
|
const { challenge } = makePkce();
|
|
@@ -6143,7 +6375,12 @@ describe("handleAuthorizeGet — stale assigned_vault surfaces banner (hub#284)"
|
|
|
6143
6375
|
test("user with intact assignment → no banner (pre-#284 happy path stays clean)", async () => {
|
|
6144
6376
|
const { db, cleanup } = await makeDb();
|
|
6145
6377
|
try {
|
|
6146
|
-
const
|
|
6378
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6379
|
+
void admin;
|
|
6380
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6381
|
+
allowMulti: true,
|
|
6382
|
+
assignedVaults: ["default"],
|
|
6383
|
+
});
|
|
6147
6384
|
const session = createSession(db, { userId: bob.id });
|
|
6148
6385
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6149
6386
|
const { challenge } = makePkce();
|
|
@@ -6195,7 +6432,12 @@ describe("handleAuthorizePost — stale assigned_vault clean 400 (hub#284)", ()
|
|
|
6195
6432
|
test("hand-crafted POST with stale vault_pick → 'Assigned vault was removed' 400 (not generic Unknown vault)", async () => {
|
|
6196
6433
|
const { db, cleanup } = await makeDb();
|
|
6197
6434
|
try {
|
|
6198
|
-
const
|
|
6435
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6436
|
+
void admin;
|
|
6437
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6438
|
+
allowMulti: true,
|
|
6439
|
+
assignedVaults: ["default"],
|
|
6440
|
+
});
|
|
6199
6441
|
const session = createSession(db, { userId: bob.id });
|
|
6200
6442
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6201
6443
|
const { challenge } = makePkce();
|
|
@@ -6241,7 +6483,12 @@ describe("handleAuthorizePost — stale assigned_vault clean 400 (hub#284)", ()
|
|
|
6241
6483
|
test("hand-crafted POST with vault_pick naming a never-existed vault → still hits generic Unknown vault 400", async () => {
|
|
6242
6484
|
const { db, cleanup } = await makeDb();
|
|
6243
6485
|
try {
|
|
6244
|
-
const
|
|
6486
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6487
|
+
void admin;
|
|
6488
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6489
|
+
allowMulti: true,
|
|
6490
|
+
assignedVaults: ["default"],
|
|
6491
|
+
});
|
|
6245
6492
|
const session = createSession(db, { userId: bob.id });
|
|
6246
6493
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6247
6494
|
const { challenge } = makePkce();
|
|
@@ -6289,7 +6536,12 @@ describe("handleAuthorizePost — stale assigned_vault clean 400 (hub#284)", ()
|
|
|
6289
6536
|
test("named scope vault:<stale>:read → POST refuses with 'Assigned vault was removed' 400", async () => {
|
|
6290
6537
|
const { db, cleanup } = await makeDb();
|
|
6291
6538
|
try {
|
|
6292
|
-
const
|
|
6539
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6540
|
+
void admin;
|
|
6541
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6542
|
+
allowMulti: true,
|
|
6543
|
+
assignedVaults: ["default"],
|
|
6544
|
+
});
|
|
6293
6545
|
const session = createSession(db, { userId: bob.id });
|
|
6294
6546
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6295
6547
|
const { challenge } = makePkce();
|
|
@@ -6361,7 +6613,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
|
|
|
6361
6613
|
// Post-fold the gate skips, the consent screen renders, banner shows.
|
|
6362
6614
|
const { db, cleanup } = await makeDb();
|
|
6363
6615
|
try {
|
|
6364
|
-
const
|
|
6616
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6617
|
+
void admin;
|
|
6618
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6619
|
+
allowMulti: true,
|
|
6620
|
+
assignedVaults: ["default"],
|
|
6621
|
+
});
|
|
6365
6622
|
const session = createSession(db, { userId: bob.id });
|
|
6366
6623
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6367
6624
|
const { recordGrant } = await import("../grants.ts");
|
|
@@ -6409,7 +6666,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
|
|
|
6409
6666
|
// consent screen renders.
|
|
6410
6667
|
const { db, cleanup } = await makeDb();
|
|
6411
6668
|
try {
|
|
6412
|
-
const
|
|
6669
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6670
|
+
void admin;
|
|
6671
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6672
|
+
allowMulti: true,
|
|
6673
|
+
assignedVaults: ["default"],
|
|
6674
|
+
});
|
|
6413
6675
|
const session = createSession(db, { userId: bob.id });
|
|
6414
6676
|
const reg = registerClient(db, {
|
|
6415
6677
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -6456,7 +6718,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
|
|
|
6456
6718
|
// enabled — the user can still consent to scribe access.
|
|
6457
6719
|
const { db, cleanup } = await makeDb();
|
|
6458
6720
|
try {
|
|
6459
|
-
const
|
|
6721
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6722
|
+
void admin;
|
|
6723
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6724
|
+
allowMulti: true,
|
|
6725
|
+
assignedVaults: ["default"],
|
|
6726
|
+
});
|
|
6460
6727
|
const session = createSession(db, { userId: bob.id });
|
|
6461
6728
|
const reg = registerClient(db, {
|
|
6462
6729
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -6501,7 +6768,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
|
|
|
6501
6768
|
// the fast-path so the fold doesn't break the existing UX.
|
|
6502
6769
|
const { db, cleanup } = await makeDb();
|
|
6503
6770
|
try {
|
|
6504
|
-
const
|
|
6771
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6772
|
+
void admin;
|
|
6773
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6774
|
+
allowMulti: true,
|
|
6775
|
+
assignedVaults: ["default"],
|
|
6776
|
+
});
|
|
6505
6777
|
const session = createSession(db, { userId: bob.id });
|
|
6506
6778
|
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
6507
6779
|
const { recordGrant } = await import("../grants.ts");
|
|
@@ -6543,7 +6815,12 @@ describe("handleAuthorizeGet — stale assignment gates both fast-paths (hub#284
|
|
|
6543
6815
|
// non-admin vault scope, user's assignment is intact, gate fires.
|
|
6544
6816
|
const { db, cleanup } = await makeDb();
|
|
6545
6817
|
try {
|
|
6546
|
-
const
|
|
6818
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
6819
|
+
void admin;
|
|
6820
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
6821
|
+
allowMulti: true,
|
|
6822
|
+
assignedVaults: ["default"],
|
|
6823
|
+
});
|
|
6547
6824
|
const session = createSession(db, { userId: bob.id });
|
|
6548
6825
|
const reg = registerClient(db, {
|
|
6549
6826
|
redirectUris: ["https://app.example/cb"],
|
|
@@ -6776,3 +7053,757 @@ describe("handleAuthorizeGet — trust-by-client_name auto-approve (hub#409)", (
|
|
|
6776
7053
|
}
|
|
6777
7054
|
});
|
|
6778
7055
|
});
|
|
7056
|
+
|
|
7057
|
+
// Phase 2 PR 2 reviewer fold (hub#429 reviewer): a non-admin user with
|
|
7058
|
+
// ZERO `user_vaults` rows is a known-but-not-yet-assigned posture. They can
|
|
7059
|
+
// sign in to /account/, change their password, and see the home page, but
|
|
7060
|
+
// they have no vaults to authorize against. The original Phase 2 PR 2 left
|
|
7061
|
+
// three OAuth privilege-escalation paths open:
|
|
7062
|
+
//
|
|
7063
|
+
// 1. Named scope consent submit: handleConsentSubmit's vault-validation
|
|
7064
|
+
// gates ran only when `isPinned = assignedVaults.length > 0`. Zero-
|
|
7065
|
+
// vault non-admin slipped past every gate, minted a token with the
|
|
7066
|
+
// admin "unrestricted" vault_scope sentinel ([]).
|
|
7067
|
+
//
|
|
7068
|
+
// 2. Same-hub auto-trust gate (hub#312): zero-vault non-admin satisfied
|
|
7069
|
+
// every condition (no admin scope, no unnamed verb, no stale-
|
|
7070
|
+
// assignment — length === 0 short-circuits the stale predicate).
|
|
7071
|
+
// Silently minted a token, no consent screen.
|
|
7072
|
+
//
|
|
7073
|
+
// 3. Unnamed-scope picker: the empty-`assignedVaults` branch of
|
|
7074
|
+
// `consentProps` rendered the FULL hub-wide vault list, letting a
|
|
7075
|
+
// zero-vault non-admin pick any vault on the hub.
|
|
7076
|
+
//
|
|
7077
|
+
// The fix gates all three paths. These tests pin each one + verify the
|
|
7078
|
+
// first-admin happy path (`vaultScopeForUser` returns [] for first admin
|
|
7079
|
+
// too, and that posture must NOT regress to "no access").
|
|
7080
|
+
describe("zero-vault non-admin privesc gate (hub#429 reviewer)", () => {
|
|
7081
|
+
test("named scope consent submit → blocked with vault_scope_mismatch (path 1)", async () => {
|
|
7082
|
+
const { db, cleanup } = await makeDb();
|
|
7083
|
+
try {
|
|
7084
|
+
// First admin exists so `bob` is a non-admin.
|
|
7085
|
+
await createUser(db, "admin-aaron", "pw");
|
|
7086
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
7087
|
+
allowMulti: true,
|
|
7088
|
+
// No assigned vaults — the "known but not yet assigned" posture.
|
|
7089
|
+
});
|
|
7090
|
+
expect(bob.assignedVaults).toEqual([]);
|
|
7091
|
+
const session = createSession(db, { userId: bob.id });
|
|
7092
|
+
const reg = registerClient(db, {
|
|
7093
|
+
redirectUris: ["https://app.example/cb"],
|
|
7094
|
+
status: "approved",
|
|
7095
|
+
});
|
|
7096
|
+
const { challenge } = makePkce();
|
|
7097
|
+
// Hand-crafted POST naming a vault bob has no business consenting to.
|
|
7098
|
+
const form = new URLSearchParams({
|
|
7099
|
+
__action: "consent",
|
|
7100
|
+
__csrf: TEST_CSRF,
|
|
7101
|
+
approve: "yes",
|
|
7102
|
+
client_id: reg.client.clientId,
|
|
7103
|
+
redirect_uri: "https://app.example/cb",
|
|
7104
|
+
response_type: "code",
|
|
7105
|
+
scope: "vault:default:read",
|
|
7106
|
+
code_challenge: challenge,
|
|
7107
|
+
code_challenge_method: "S256",
|
|
7108
|
+
state: "zv-1",
|
|
7109
|
+
});
|
|
7110
|
+
const req = new Request(`${ISSUER}/oauth/authorize`, {
|
|
7111
|
+
method: "POST",
|
|
7112
|
+
body: form,
|
|
7113
|
+
headers: {
|
|
7114
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
7115
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
7116
|
+
},
|
|
7117
|
+
});
|
|
7118
|
+
const res = await handleAuthorizePost(db, req, {
|
|
7119
|
+
issuer: ISSUER,
|
|
7120
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7121
|
+
});
|
|
7122
|
+
// NOT a 302 redirect (which would mean the auth code was issued).
|
|
7123
|
+
expect(res.status).not.toBe(302);
|
|
7124
|
+
expect(res.status).toBe(400);
|
|
7125
|
+
const html = await res.text();
|
|
7126
|
+
expect(html).toContain("vault_scope_mismatch");
|
|
7127
|
+
expect(html).toContain("No vaults assigned");
|
|
7128
|
+
} finally {
|
|
7129
|
+
cleanup();
|
|
7130
|
+
}
|
|
7131
|
+
});
|
|
7132
|
+
|
|
7133
|
+
test("same-hub auto-trust GET with named vault scope → consent shown, not silent grant (path 2)", async () => {
|
|
7134
|
+
const { db, cleanup } = await makeDb();
|
|
7135
|
+
try {
|
|
7136
|
+
await createUser(db, "admin-aaron", "pw");
|
|
7137
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
7138
|
+
allowMulti: true,
|
|
7139
|
+
});
|
|
7140
|
+
const session = createSession(db, { userId: bob.id });
|
|
7141
|
+
// Same-hub client (DCR'd by the operator, sameHub=true).
|
|
7142
|
+
const reg = registerClient(db, {
|
|
7143
|
+
redirectUris: ["https://app.example/cb"],
|
|
7144
|
+
status: "approved",
|
|
7145
|
+
sameHub: true,
|
|
7146
|
+
});
|
|
7147
|
+
const { challenge } = makePkce();
|
|
7148
|
+
const req = new Request(
|
|
7149
|
+
authorizeUrl({
|
|
7150
|
+
client_id: reg.client.clientId,
|
|
7151
|
+
redirect_uri: "https://app.example/cb",
|
|
7152
|
+
response_type: "code",
|
|
7153
|
+
scope: "vault:default:read",
|
|
7154
|
+
code_challenge: challenge,
|
|
7155
|
+
code_challenge_method: "S256",
|
|
7156
|
+
state: "zv-2",
|
|
7157
|
+
}),
|
|
7158
|
+
{
|
|
7159
|
+
headers: {
|
|
7160
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7161
|
+
},
|
|
7162
|
+
},
|
|
7163
|
+
);
|
|
7164
|
+
const res = handleAuthorizeGet(db, req, {
|
|
7165
|
+
issuer: ISSUER,
|
|
7166
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7167
|
+
});
|
|
7168
|
+
// Pre-fix: 302 with auth code (silent mint). Post-fix: 200 HTML
|
|
7169
|
+
// consent screen — falls through the same-hub gate to the consent
|
|
7170
|
+
// render.
|
|
7171
|
+
expect(res.status).toBe(200);
|
|
7172
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
7173
|
+
} finally {
|
|
7174
|
+
cleanup();
|
|
7175
|
+
}
|
|
7176
|
+
});
|
|
7177
|
+
|
|
7178
|
+
test("unnamed vault scope GET → empty-state picker with no-assignments copy, not hub-wide list (path 3)", async () => {
|
|
7179
|
+
const { db, cleanup } = await makeDb();
|
|
7180
|
+
try {
|
|
7181
|
+
await createUser(db, "admin-aaron", "pw");
|
|
7182
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
7183
|
+
allowMulti: true,
|
|
7184
|
+
});
|
|
7185
|
+
const session = createSession(db, { userId: bob.id });
|
|
7186
|
+
const reg = registerClient(db, {
|
|
7187
|
+
redirectUris: ["https://app.example/cb"],
|
|
7188
|
+
status: "approved",
|
|
7189
|
+
});
|
|
7190
|
+
const { challenge } = makePkce();
|
|
7191
|
+
const req = new Request(
|
|
7192
|
+
authorizeUrl({
|
|
7193
|
+
client_id: reg.client.clientId,
|
|
7194
|
+
redirect_uri: "https://app.example/cb",
|
|
7195
|
+
response_type: "code",
|
|
7196
|
+
// Unnamed `vault:read` — needs the picker to narrow.
|
|
7197
|
+
scope: "vault:read",
|
|
7198
|
+
code_challenge: challenge,
|
|
7199
|
+
code_challenge_method: "S256",
|
|
7200
|
+
}),
|
|
7201
|
+
{
|
|
7202
|
+
headers: {
|
|
7203
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7204
|
+
},
|
|
7205
|
+
},
|
|
7206
|
+
);
|
|
7207
|
+
const res = handleAuthorizeGet(db, req, {
|
|
7208
|
+
issuer: ISSUER,
|
|
7209
|
+
// Manifest with TWO vaults — pre-fix the picker would render BOTH
|
|
7210
|
+
// as a free dropdown for bob (the "admin posture" empty-vaults
|
|
7211
|
+
// branch).
|
|
7212
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7213
|
+
});
|
|
7214
|
+
expect(res.status).toBe(200);
|
|
7215
|
+
const html = await res.text();
|
|
7216
|
+
// No-assignments empty-state picker. Approve disabled.
|
|
7217
|
+
expect(html).toContain("you have no vaults assigned on this hub yet");
|
|
7218
|
+
expect(html).toContain("/admin/users");
|
|
7219
|
+
// NO hub-wide vault picker options rendered (the `default` and any
|
|
7220
|
+
// other vault from the fixture must not appear as radio options).
|
|
7221
|
+
expect(html).not.toMatch(/<input type="radio" name="vault_pick"/);
|
|
7222
|
+
// Approve button disabled.
|
|
7223
|
+
expect(html).toMatch(/<button[^>]*name="approve"[^>]*value="yes"[^>]*disabled/);
|
|
7224
|
+
} finally {
|
|
7225
|
+
cleanup();
|
|
7226
|
+
}
|
|
7227
|
+
});
|
|
7228
|
+
|
|
7229
|
+
test("token endpoint cannot mint with empty vault_scope for zero-vault non-admin (defense in depth, path 4)", async () => {
|
|
7230
|
+
// If the auth-code path is fully blocked above, the user can never
|
|
7231
|
+
// get an auth code in the first place — this verifies the auth-code
|
|
7232
|
+
// path stays blocked at the consent-submit boundary so no code is
|
|
7233
|
+
// issued (the token endpoint never sees one for this user posture).
|
|
7234
|
+
const { db, cleanup } = await makeDb();
|
|
7235
|
+
try {
|
|
7236
|
+
await createUser(db, "admin-aaron", "pw");
|
|
7237
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
7238
|
+
allowMulti: true,
|
|
7239
|
+
});
|
|
7240
|
+
const session = createSession(db, { userId: bob.id });
|
|
7241
|
+
const reg = registerClient(db, {
|
|
7242
|
+
redirectUris: ["https://app.example/cb"],
|
|
7243
|
+
status: "approved",
|
|
7244
|
+
});
|
|
7245
|
+
const { challenge } = makePkce();
|
|
7246
|
+
const form = new URLSearchParams({
|
|
7247
|
+
__action: "consent",
|
|
7248
|
+
__csrf: TEST_CSRF,
|
|
7249
|
+
approve: "yes",
|
|
7250
|
+
client_id: reg.client.clientId,
|
|
7251
|
+
redirect_uri: "https://app.example/cb",
|
|
7252
|
+
response_type: "code",
|
|
7253
|
+
scope: "vault:default:read",
|
|
7254
|
+
code_challenge: challenge,
|
|
7255
|
+
code_challenge_method: "S256",
|
|
7256
|
+
});
|
|
7257
|
+
const req = new Request(`${ISSUER}/oauth/authorize`, {
|
|
7258
|
+
method: "POST",
|
|
7259
|
+
body: form,
|
|
7260
|
+
headers: {
|
|
7261
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
7262
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, 86400)}`,
|
|
7263
|
+
},
|
|
7264
|
+
});
|
|
7265
|
+
const res = await handleAuthorizePost(db, req, {
|
|
7266
|
+
issuer: ISSUER,
|
|
7267
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7268
|
+
});
|
|
7269
|
+
// No 302 — no auth code redirect, no `code` param ever issued.
|
|
7270
|
+
expect(res.status).toBe(400);
|
|
7271
|
+
expect(res.headers.get("location")).toBeNull();
|
|
7272
|
+
// Belt-and-suspenders: `vaultScopeForUser` would return [] for
|
|
7273
|
+
// bob (the very sentinel the OAuth flow refuses to mint into a
|
|
7274
|
+
// token for non-admin users). Pin the helper's behavior so a
|
|
7275
|
+
// future change can't silently lift it to "include all vaults".
|
|
7276
|
+
expect(vaultScopeForUser(db, bob.id)).toEqual([]);
|
|
7277
|
+
} finally {
|
|
7278
|
+
cleanup();
|
|
7279
|
+
}
|
|
7280
|
+
});
|
|
7281
|
+
|
|
7282
|
+
test("stale grant survives setUserVaults([]) → skip-consent gate fires consent screen, not silent code (path 5)", async () => {
|
|
7283
|
+
// The 4th attack the prior fold didn't cover. Sequence:
|
|
7284
|
+
// 1. bob has assignedVaults=["default"] (legitimate).
|
|
7285
|
+
// 2. bob consents to a vault-scoped client → grants row recorded.
|
|
7286
|
+
// 3. Admin clears bob's assignments via setUserVaults(_, []).
|
|
7287
|
+
// 4. Grants table has no FK cascade from user_vaults, so the
|
|
7288
|
+
// grant row survives the assignment delete.
|
|
7289
|
+
// 5. bob re-hits /oauth/authorize?scope=vault:default:read for
|
|
7290
|
+
// the same client. Pre-fix: skip-consent gate fires
|
|
7291
|
+
// (isCoveredByGrant=true), silent 302 with auth code and
|
|
7292
|
+
// vault_scope=[] (the admin "unrestricted" sentinel).
|
|
7293
|
+
// Post-fix: userHasVaultPosture=false → fall through to
|
|
7294
|
+
// consent render where the zero-vault gate also refuses.
|
|
7295
|
+
const { db, cleanup } = await makeDb();
|
|
7296
|
+
try {
|
|
7297
|
+
await createUser(db, "admin-aaron", "pw");
|
|
7298
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
7299
|
+
allowMulti: true,
|
|
7300
|
+
assignedVaults: ["default"],
|
|
7301
|
+
});
|
|
7302
|
+
expect(bob.assignedVaults).toEqual(["default"]);
|
|
7303
|
+
const session = createSession(db, { userId: bob.id });
|
|
7304
|
+
const reg = registerClient(db, {
|
|
7305
|
+
redirectUris: ["https://app.example/cb"],
|
|
7306
|
+
status: "approved",
|
|
7307
|
+
});
|
|
7308
|
+
// Record the grant while bob still has the assignment.
|
|
7309
|
+
recordGrant(db, bob.id, reg.client.clientId, ["vault:default:read"]);
|
|
7310
|
+
// Admin clears bob's assignments. Grant row is NOT cascaded.
|
|
7311
|
+
expect(setUserVaults(db, bob.id, [])).toBe(true);
|
|
7312
|
+
expect(findGrant(db, bob.id, reg.client.clientId)).not.toBeNull();
|
|
7313
|
+
const { challenge } = makePkce();
|
|
7314
|
+
const req = new Request(
|
|
7315
|
+
authorizeUrl({
|
|
7316
|
+
client_id: reg.client.clientId,
|
|
7317
|
+
redirect_uri: "https://app.example/cb",
|
|
7318
|
+
response_type: "code",
|
|
7319
|
+
scope: "vault:default:read",
|
|
7320
|
+
code_challenge: challenge,
|
|
7321
|
+
code_challenge_method: "S256",
|
|
7322
|
+
state: "stale-grant",
|
|
7323
|
+
}),
|
|
7324
|
+
{
|
|
7325
|
+
headers: {
|
|
7326
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7327
|
+
},
|
|
7328
|
+
},
|
|
7329
|
+
);
|
|
7330
|
+
const res = handleAuthorizeGet(db, req, {
|
|
7331
|
+
issuer: ISSUER,
|
|
7332
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7333
|
+
});
|
|
7334
|
+
// Post-fix: 200 HTML consent screen, NOT 302 with code.
|
|
7335
|
+
expect(res.status).toBe(200);
|
|
7336
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
7337
|
+
expect(res.headers.get("location")).toBeNull();
|
|
7338
|
+
} finally {
|
|
7339
|
+
cleanup();
|
|
7340
|
+
}
|
|
7341
|
+
});
|
|
7342
|
+
|
|
7343
|
+
test("trust-by-client_name auto-promote → recursive re-entry hits skip-consent gate (path 6)", async () => {
|
|
7344
|
+
// The trust-by-client_name path (hub#409, ~line 554) recursively
|
|
7345
|
+
// calls handleAuthorizeGet after approveClient + recordGrant on
|
|
7346
|
+
// the fresh client_id. That recursive call now passes through
|
|
7347
|
+
// the skip-consent gate with our new userHasVaultPosture check.
|
|
7348
|
+
//
|
|
7349
|
+
// Sequence:
|
|
7350
|
+
// 1. bob has assignedVaults=["default"], consents to "claude-code"
|
|
7351
|
+
// (prior client_id) for vault:default:read.
|
|
7352
|
+
// 2. Admin clears bob's assignments.
|
|
7353
|
+
// 3. Claude re-DCRs a fresh "claude-code" with a new client_id
|
|
7354
|
+
// (status=pending).
|
|
7355
|
+
// 4. bob hits /oauth/authorize on the fresh client_id.
|
|
7356
|
+
// 5. Pre-fix: trust-by-client_name matches by name+scope,
|
|
7357
|
+
// approves the fresh client, records grant, recurses into
|
|
7358
|
+
// handleAuthorizeGet — which now finds a covering grant and
|
|
7359
|
+
// silently mints the auth code with vault_scope=[].
|
|
7360
|
+
// Post-fix: the recursive call's skip-consent gate sees
|
|
7361
|
+
// userHasVaultPosture=false and falls through to the consent
|
|
7362
|
+
// render. Same-hub gate also refuses (sameHub=false here, but
|
|
7363
|
+
// the posture check is the load-bearing constraint).
|
|
7364
|
+
const { db, cleanup } = await makeDb();
|
|
7365
|
+
try {
|
|
7366
|
+
await createUser(db, "admin-aaron", "pw");
|
|
7367
|
+
const bob = await createUser(db, "bob", "pw", {
|
|
7368
|
+
allowMulti: true,
|
|
7369
|
+
assignedVaults: ["default"],
|
|
7370
|
+
});
|
|
7371
|
+
const session = createSession(db, { userId: bob.id });
|
|
7372
|
+
// Prior approved client_name="claude-code" + grant.
|
|
7373
|
+
const prior = registerClient(db, {
|
|
7374
|
+
redirectUris: ["https://app.example/cb"],
|
|
7375
|
+
status: "approved",
|
|
7376
|
+
clientName: "claude-code",
|
|
7377
|
+
});
|
|
7378
|
+
recordGrant(db, bob.id, prior.client.clientId, ["vault:default:read"]);
|
|
7379
|
+
// Admin clears bob's assignments. Prior grant survives.
|
|
7380
|
+
expect(setUserVaults(db, bob.id, [])).toBe(true);
|
|
7381
|
+
// Fresh DCR — same client_name, fresh client_id, status=pending.
|
|
7382
|
+
const fresh = registerClient(db, {
|
|
7383
|
+
redirectUris: ["https://app.example/cb"],
|
|
7384
|
+
status: "pending",
|
|
7385
|
+
clientName: "claude-code",
|
|
7386
|
+
});
|
|
7387
|
+
const { challenge } = makePkce();
|
|
7388
|
+
const req = new Request(
|
|
7389
|
+
authorizeUrl({
|
|
7390
|
+
client_id: fresh.client.clientId,
|
|
7391
|
+
redirect_uri: "https://app.example/cb",
|
|
7392
|
+
response_type: "code",
|
|
7393
|
+
code_challenge: challenge,
|
|
7394
|
+
code_challenge_method: "S256",
|
|
7395
|
+
scope: "vault:default:read",
|
|
7396
|
+
state: "trust-by-name-zero",
|
|
7397
|
+
}),
|
|
7398
|
+
{
|
|
7399
|
+
headers: {
|
|
7400
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7401
|
+
origin: ISSUER,
|
|
7402
|
+
},
|
|
7403
|
+
},
|
|
7404
|
+
);
|
|
7405
|
+
const res = handleAuthorizeGet(db, req, {
|
|
7406
|
+
issuer: ISSUER,
|
|
7407
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7408
|
+
});
|
|
7409
|
+
// Post-fix: 200 HTML consent screen, NOT 302 with code. The
|
|
7410
|
+
// fresh client_id IS approved (the auto-promote ran), and a
|
|
7411
|
+
// grant IS recorded — that's the design of hub#409. The
|
|
7412
|
+
// load-bearing assertion is that the recursive re-entry into
|
|
7413
|
+
// handleAuthorizeGet did NOT issue an auth code, because the
|
|
7414
|
+
// zero-vault posture failed our gate.
|
|
7415
|
+
expect(res.status).toBe(200);
|
|
7416
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
7417
|
+
expect(res.headers.get("location")).toBeNull();
|
|
7418
|
+
expect(getClient(db, fresh.client.clientId)?.status).toBe("approved");
|
|
7419
|
+
} finally {
|
|
7420
|
+
cleanup();
|
|
7421
|
+
}
|
|
7422
|
+
});
|
|
7423
|
+
|
|
7424
|
+
test("first admin with no vault assignments still gets unrestricted access (regression guard)", async () => {
|
|
7425
|
+
// First admin's `assignedVaults` is empty by design — that's the
|
|
7426
|
+
// admin "unrestricted" sentinel. The zero-vault gate must NOT
|
|
7427
|
+
// catch the first admin: they're not "non-admin with no
|
|
7428
|
+
// assignments," they're "admin posture, empty list is the signal."
|
|
7429
|
+
const { db, cleanup } = await makeDb();
|
|
7430
|
+
try {
|
|
7431
|
+
// Only one user — the first admin. No `user_vaults` rows.
|
|
7432
|
+
const admin = await createUser(db, "admin-aaron", "pw");
|
|
7433
|
+
expect(admin.assignedVaults).toEqual([]);
|
|
7434
|
+
const session = createSession(db, { userId: admin.id });
|
|
7435
|
+
const reg = registerClient(db, {
|
|
7436
|
+
redirectUris: ["https://app.example/cb"],
|
|
7437
|
+
status: "approved",
|
|
7438
|
+
sameHub: true,
|
|
7439
|
+
});
|
|
7440
|
+
const { challenge } = makePkce();
|
|
7441
|
+
const req = new Request(
|
|
7442
|
+
authorizeUrl({
|
|
7443
|
+
client_id: reg.client.clientId,
|
|
7444
|
+
redirect_uri: "https://app.example/cb",
|
|
7445
|
+
response_type: "code",
|
|
7446
|
+
scope: "vault:default:read",
|
|
7447
|
+
code_challenge: challenge,
|
|
7448
|
+
code_challenge_method: "S256",
|
|
7449
|
+
state: "first-admin-ok",
|
|
7450
|
+
}),
|
|
7451
|
+
{
|
|
7452
|
+
headers: {
|
|
7453
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7454
|
+
},
|
|
7455
|
+
},
|
|
7456
|
+
);
|
|
7457
|
+
const res = handleAuthorizeGet(db, req, {
|
|
7458
|
+
issuer: ISSUER,
|
|
7459
|
+
loadServicesManifest: fixtureLoadServicesManifest,
|
|
7460
|
+
});
|
|
7461
|
+
// Silent grant — same-hub auto-trust fires for the first admin
|
|
7462
|
+
// exactly as it did pre-fix.
|
|
7463
|
+
expect(res.status).toBe(302);
|
|
7464
|
+
const loc = new URL(res.headers.get("location") ?? "");
|
|
7465
|
+
expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
|
|
7466
|
+
expect(loc.searchParams.get("code")?.length).toBeGreaterThan(20);
|
|
7467
|
+
expect(loc.searchParams.get("state")).toBe("first-admin-ok");
|
|
7468
|
+
} finally {
|
|
7469
|
+
cleanup();
|
|
7470
|
+
}
|
|
7471
|
+
});
|
|
7472
|
+
});
|
|
7473
|
+
|
|
7474
|
+
// RFC 8707 resource binding (fix #461). A friend connecting an MCP client to
|
|
7475
|
+
// ONE vault (`<origin>/vault/<name>/mcp`) must see ONLY that vault's scopes on
|
|
7476
|
+
// consent, and the minted token must carry the narrow, NAMED scope +
|
|
7477
|
+
// `aud=vault.<name>` — otherwise (a) the consent screen is scary (whole-hub
|
|
7478
|
+
// catalog) and (b) a current-line vault REJECTS the token via
|
|
7479
|
+
// `findBroadVaultScopes` (unnamed `vault:read` → `aud=vault` → 401).
|
|
7480
|
+
//
|
|
7481
|
+
// The deps thread `hubBoundOrigins` so the resource's origin is recognized as
|
|
7482
|
+
// one the hub fronts — same set the same-origin CSRF gate consults.
|
|
7483
|
+
describe("RFC 8707 resource binding — vault-bound MCP (fix #461)", () => {
|
|
7484
|
+
const RESOURCE_DEPS = {
|
|
7485
|
+
issuer: ISSUER,
|
|
7486
|
+
loadServicesManifest: () => MULTI_VAULT_MANIFEST,
|
|
7487
|
+
hubBoundOrigins: () => [ISSUER],
|
|
7488
|
+
};
|
|
7489
|
+
|
|
7490
|
+
// Two vaults on the hub so "narrow to ONE" is observable: a request bound to
|
|
7491
|
+
// `jon` must NOT surface `boulder`'s scopes nor the rest of the catalog.
|
|
7492
|
+
const MULTI_VAULT_MANIFEST: ServicesManifest = {
|
|
7493
|
+
services: [
|
|
7494
|
+
{
|
|
7495
|
+
name: "parachute-vault",
|
|
7496
|
+
port: 1940,
|
|
7497
|
+
paths: ["/vault/jon", "/vault/boulder"],
|
|
7498
|
+
health: "/health",
|
|
7499
|
+
version: "0.6.0",
|
|
7500
|
+
},
|
|
7501
|
+
{
|
|
7502
|
+
name: "parachute-scribe",
|
|
7503
|
+
port: 1943,
|
|
7504
|
+
paths: ["/scribe"],
|
|
7505
|
+
health: "/health",
|
|
7506
|
+
version: "0.6.0",
|
|
7507
|
+
},
|
|
7508
|
+
],
|
|
7509
|
+
};
|
|
7510
|
+
|
|
7511
|
+
/**
|
|
7512
|
+
* Mirror of `parachute-vault/src/scopes.ts:findBroadVaultScopes` — the exact
|
|
7513
|
+
* predicate `authenticateHubJwt` runs to REJECT hub tokens. A token a
|
|
7514
|
+
* current-line vault accepts must (a) carry zero broad `vault:<verb>` scopes
|
|
7515
|
+
* and (b) name the vault in the audience. Inlined (vault is a separate
|
|
7516
|
+
* package, not a hub dep) so this hub test genuinely encodes vault's
|
|
7517
|
+
* contract — the cross-cutting half of the E2E gate.
|
|
7518
|
+
*/
|
|
7519
|
+
function findBroadVaultScopes(granted: string[]): string[] {
|
|
7520
|
+
return granted.filter((s) => {
|
|
7521
|
+
const parts = s.split(":");
|
|
7522
|
+
return (
|
|
7523
|
+
parts.length === 2 &&
|
|
7524
|
+
parts[0] === "vault" &&
|
|
7525
|
+
["read", "write", "admin"].includes(parts[1] ?? "")
|
|
7526
|
+
);
|
|
7527
|
+
});
|
|
7528
|
+
}
|
|
7529
|
+
|
|
7530
|
+
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 () => {
|
|
7531
|
+
const { db, cleanup } = await makeDb();
|
|
7532
|
+
try {
|
|
7533
|
+
// --- the operator (first admin) signed into the hub ---
|
|
7534
|
+
const user = await createUser(db, "owner", "pw");
|
|
7535
|
+
const session = createSession(db, { userId: user.id });
|
|
7536
|
+
const cookie = `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`;
|
|
7537
|
+
|
|
7538
|
+
// --- DCR: register the friend's MCP client (plain pending, then
|
|
7539
|
+
// operator-approve so consent renders rather than same-hub
|
|
7540
|
+
// auto-trust skipping it) ---
|
|
7541
|
+
const regRes = await handleRegister(
|
|
7542
|
+
db,
|
|
7543
|
+
new Request(`${ISSUER}/oauth/register`, {
|
|
7544
|
+
method: "POST",
|
|
7545
|
+
body: JSON.stringify({
|
|
7546
|
+
redirect_uris: ["https://claude.ai/mcp/callback"],
|
|
7547
|
+
client_name: "claude-code",
|
|
7548
|
+
scope: "vault:read vault:write",
|
|
7549
|
+
}),
|
|
7550
|
+
headers: { "content-type": "application/json" },
|
|
7551
|
+
}),
|
|
7552
|
+
RESOURCE_DEPS,
|
|
7553
|
+
);
|
|
7554
|
+
expect(regRes.status).toBe(201);
|
|
7555
|
+
const reg = (await regRes.json()) as { client_id: string };
|
|
7556
|
+
approveClient(db, reg.client_id);
|
|
7557
|
+
|
|
7558
|
+
// --- /authorize WITH the RFC 8707 resource indicator. The client asks
|
|
7559
|
+
// for UNNAMED vault:read/write but names the jon MCP resource. ---
|
|
7560
|
+
const { verifier, challenge } = makePkce();
|
|
7561
|
+
const authReq = new Request(
|
|
7562
|
+
authorizeUrl({
|
|
7563
|
+
client_id: reg.client_id,
|
|
7564
|
+
redirect_uri: "https://claude.ai/mcp/callback",
|
|
7565
|
+
response_type: "code",
|
|
7566
|
+
code_challenge: challenge,
|
|
7567
|
+
code_challenge_method: "S256",
|
|
7568
|
+
scope: "vault:read vault:write",
|
|
7569
|
+
resource: `${ISSUER}/vault/jon/mcp`,
|
|
7570
|
+
}),
|
|
7571
|
+
{ headers: { cookie } },
|
|
7572
|
+
);
|
|
7573
|
+
const authRes = handleAuthorizeGet(db, authReq, RESOURCE_DEPS);
|
|
7574
|
+
expect(authRes.status).toBe(200);
|
|
7575
|
+
const consentHtml = await authRes.text();
|
|
7576
|
+
|
|
7577
|
+
// Consent shows ONLY jon's scopes — narrowed + locked, no whole-hub
|
|
7578
|
+
// catalog, no dropdown to guess, no other vault.
|
|
7579
|
+
expect(consentHtml).toContain("vault:jon:read");
|
|
7580
|
+
expect(consentHtml).toContain("vault:jon:write");
|
|
7581
|
+
// Scary-scope guard: the friend never sees the rest of the catalog or
|
|
7582
|
+
// the other vault.
|
|
7583
|
+
expect(consentHtml).not.toContain("vault:boulder");
|
|
7584
|
+
expect(consentHtml).not.toContain("hub:admin");
|
|
7585
|
+
expect(consentHtml).not.toContain("scribe:");
|
|
7586
|
+
// No vault-picker dropdown — the vault is locked to jon by the resource.
|
|
7587
|
+
expect(consentHtml).not.toContain('name="vault_pick"');
|
|
7588
|
+
|
|
7589
|
+
// --- consent submit (approve). The hidden inputs already carry the
|
|
7590
|
+
// narrowed named scopes; the POST path re-narrows defensively. ---
|
|
7591
|
+
const consentForm = new URLSearchParams({
|
|
7592
|
+
__action: "consent",
|
|
7593
|
+
__csrf: TEST_CSRF,
|
|
7594
|
+
approve: "yes",
|
|
7595
|
+
client_id: reg.client_id,
|
|
7596
|
+
redirect_uri: "https://claude.ai/mcp/callback",
|
|
7597
|
+
response_type: "code",
|
|
7598
|
+
scope: "vault:jon:read vault:jon:write",
|
|
7599
|
+
code_challenge: challenge,
|
|
7600
|
+
code_challenge_method: "S256",
|
|
7601
|
+
resource: `${ISSUER}/vault/jon/mcp`,
|
|
7602
|
+
});
|
|
7603
|
+
const consentRes = await handleAuthorizePost(
|
|
7604
|
+
db,
|
|
7605
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
7606
|
+
method: "POST",
|
|
7607
|
+
body: consentForm,
|
|
7608
|
+
headers: { "content-type": "application/x-www-form-urlencoded", cookie },
|
|
7609
|
+
}),
|
|
7610
|
+
RESOURCE_DEPS,
|
|
7611
|
+
);
|
|
7612
|
+
expect(consentRes.status).toBe(302);
|
|
7613
|
+
const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
|
|
7614
|
+
expect(code).toBeTruthy();
|
|
7615
|
+
|
|
7616
|
+
// --- /token exchange ---
|
|
7617
|
+
const tokenRes = await handleToken(
|
|
7618
|
+
db,
|
|
7619
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
7620
|
+
method: "POST",
|
|
7621
|
+
body: new URLSearchParams({
|
|
7622
|
+
grant_type: "authorization_code",
|
|
7623
|
+
code: code ?? "",
|
|
7624
|
+
client_id: reg.client_id,
|
|
7625
|
+
redirect_uri: "https://claude.ai/mcp/callback",
|
|
7626
|
+
code_verifier: verifier,
|
|
7627
|
+
}),
|
|
7628
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
7629
|
+
}),
|
|
7630
|
+
RESOURCE_DEPS,
|
|
7631
|
+
);
|
|
7632
|
+
expect(tokenRes.status).toBe(200);
|
|
7633
|
+
const tok = (await tokenRes.json()) as { access_token: string; scope: string };
|
|
7634
|
+
|
|
7635
|
+
// Wire-level scope: NAMED + narrow — NOT the catalog, NOT unnamed.
|
|
7636
|
+
expect(tok.scope).toBe("vault:jon:read vault:jon:write");
|
|
7637
|
+
|
|
7638
|
+
// --- minted access token claims ---
|
|
7639
|
+
const { payload } = await validateAccessToken(db, tok.access_token, ISSUER);
|
|
7640
|
+
expect(payload.aud).toBe("vault.jon"); // resource-bound audience (RFC 8707)
|
|
7641
|
+
expect(payload.scope).toBe("vault:jon:read vault:jon:write");
|
|
7642
|
+
expect(payload.iss).toBe(ISSUER);
|
|
7643
|
+
|
|
7644
|
+
// --- CROSS-CUTTING: the token shape a current-line vault REQUIRES.
|
|
7645
|
+
// vault's `authenticateHubJwt` runs `findBroadVaultScopes` (reject
|
|
7646
|
+
// any unnamed vault verb) + audience strict-check `vault.<name>`.
|
|
7647
|
+
const grantedScopes = (payload.scope as string).split(" ");
|
|
7648
|
+
expect(findBroadVaultScopes(grantedScopes)).toEqual([]); // no broad-scope rejection
|
|
7649
|
+
expect(payload.aud).toBe("vault.jon"); // matches the URL-derived vault name at /vault/jon/mcp
|
|
7650
|
+
// Every granted scope is the named form vault accepts.
|
|
7651
|
+
for (const s of grantedScopes) {
|
|
7652
|
+
expect(s).toMatch(/^vault:jon:(read|write)$/);
|
|
7653
|
+
}
|
|
7654
|
+
} finally {
|
|
7655
|
+
cleanup();
|
|
7656
|
+
}
|
|
7657
|
+
});
|
|
7658
|
+
|
|
7659
|
+
test("resource → consent narrows to the named vault (no whole-hub catalog, picker locked)", async () => {
|
|
7660
|
+
const { db, cleanup } = await makeDb();
|
|
7661
|
+
try {
|
|
7662
|
+
const user = await createUser(db, "owner", "pw");
|
|
7663
|
+
const session = createSession(db, { userId: user.id });
|
|
7664
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
7665
|
+
const { challenge } = makePkce();
|
|
7666
|
+
const res = handleAuthorizeGet(
|
|
7667
|
+
db,
|
|
7668
|
+
new Request(
|
|
7669
|
+
authorizeUrl({
|
|
7670
|
+
client_id: reg.client.clientId,
|
|
7671
|
+
redirect_uri: "https://app.example/cb",
|
|
7672
|
+
response_type: "code",
|
|
7673
|
+
code_challenge: challenge,
|
|
7674
|
+
code_challenge_method: "S256",
|
|
7675
|
+
scope: "vault:read",
|
|
7676
|
+
resource: `${ISSUER}/vault/jon/.well-known/oauth-protected-resource`,
|
|
7677
|
+
}),
|
|
7678
|
+
{
|
|
7679
|
+
headers: {
|
|
7680
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7681
|
+
},
|
|
7682
|
+
},
|
|
7683
|
+
),
|
|
7684
|
+
RESOURCE_DEPS,
|
|
7685
|
+
);
|
|
7686
|
+
expect(res.status).toBe(200);
|
|
7687
|
+
const html = await res.text();
|
|
7688
|
+
// PRM-URL form of the resource resolves to jon too.
|
|
7689
|
+
expect(html).toContain("vault:jon:read");
|
|
7690
|
+
expect(html).not.toContain('name="vault_pick"');
|
|
7691
|
+
expect(html).not.toContain("vault:boulder");
|
|
7692
|
+
} finally {
|
|
7693
|
+
cleanup();
|
|
7694
|
+
}
|
|
7695
|
+
});
|
|
7696
|
+
|
|
7697
|
+
test("no resource param → behavior unchanged (unnamed scope still renders the picker)", async () => {
|
|
7698
|
+
const { db, cleanup } = await makeDb();
|
|
7699
|
+
try {
|
|
7700
|
+
const user = await createUser(db, "owner", "pw");
|
|
7701
|
+
const session = createSession(db, { userId: user.id });
|
|
7702
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
7703
|
+
const { challenge } = makePkce();
|
|
7704
|
+
const res = handleAuthorizeGet(
|
|
7705
|
+
db,
|
|
7706
|
+
new Request(
|
|
7707
|
+
authorizeUrl({
|
|
7708
|
+
client_id: reg.client.clientId,
|
|
7709
|
+
redirect_uri: "https://app.example/cb",
|
|
7710
|
+
response_type: "code",
|
|
7711
|
+
code_challenge: challenge,
|
|
7712
|
+
code_challenge_method: "S256",
|
|
7713
|
+
scope: "vault:read",
|
|
7714
|
+
// no resource param
|
|
7715
|
+
}),
|
|
7716
|
+
{
|
|
7717
|
+
headers: {
|
|
7718
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7719
|
+
},
|
|
7720
|
+
},
|
|
7721
|
+
),
|
|
7722
|
+
RESOURCE_DEPS,
|
|
7723
|
+
);
|
|
7724
|
+
expect(res.status).toBe(200);
|
|
7725
|
+
const html = await res.text();
|
|
7726
|
+
// Manual-pick path preserved: picker renders, vault not pre-narrowed.
|
|
7727
|
+
expect(html).toContain("Pick a vault");
|
|
7728
|
+
expect(html).toContain('name="vault_pick"');
|
|
7729
|
+
} finally {
|
|
7730
|
+
cleanup();
|
|
7731
|
+
}
|
|
7732
|
+
});
|
|
7733
|
+
|
|
7734
|
+
test("off-origin resource → ignored (no narrowing; manual-pick path)", async () => {
|
|
7735
|
+
const { db, cleanup } = await makeDb();
|
|
7736
|
+
try {
|
|
7737
|
+
const user = await createUser(db, "owner", "pw");
|
|
7738
|
+
const session = createSession(db, { userId: user.id });
|
|
7739
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
7740
|
+
const { challenge } = makePkce();
|
|
7741
|
+
const res = handleAuthorizeGet(
|
|
7742
|
+
db,
|
|
7743
|
+
new Request(
|
|
7744
|
+
authorizeUrl({
|
|
7745
|
+
client_id: reg.client.clientId,
|
|
7746
|
+
redirect_uri: "https://app.example/cb",
|
|
7747
|
+
response_type: "code",
|
|
7748
|
+
code_challenge: challenge,
|
|
7749
|
+
code_challenge_method: "S256",
|
|
7750
|
+
scope: "vault:read",
|
|
7751
|
+
resource: "https://evil.example/vault/jon/mcp",
|
|
7752
|
+
}),
|
|
7753
|
+
{
|
|
7754
|
+
headers: {
|
|
7755
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7756
|
+
},
|
|
7757
|
+
},
|
|
7758
|
+
),
|
|
7759
|
+
RESOURCE_DEPS,
|
|
7760
|
+
);
|
|
7761
|
+
expect(res.status).toBe(200);
|
|
7762
|
+
const html = await res.text();
|
|
7763
|
+
// An attacker-controlled resource origin can't drive narrowing — the
|
|
7764
|
+
// flow falls back to the normal manual picker.
|
|
7765
|
+
expect(html).toContain("Pick a vault");
|
|
7766
|
+
expect(html).not.toContain("vault:jon:read");
|
|
7767
|
+
} finally {
|
|
7768
|
+
cleanup();
|
|
7769
|
+
}
|
|
7770
|
+
});
|
|
7771
|
+
|
|
7772
|
+
test("non-requestable scope still refused even with a resource (vault:admin → vault:jon:admin blocked)", async () => {
|
|
7773
|
+
const { db, cleanup } = await makeDb();
|
|
7774
|
+
try {
|
|
7775
|
+
const user = await createUser(db, "owner", "pw");
|
|
7776
|
+
const session = createSession(db, { userId: user.id });
|
|
7777
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
7778
|
+
const { challenge } = makePkce();
|
|
7779
|
+
const res = handleAuthorizeGet(
|
|
7780
|
+
db,
|
|
7781
|
+
new Request(
|
|
7782
|
+
authorizeUrl({
|
|
7783
|
+
client_id: reg.client.clientId,
|
|
7784
|
+
redirect_uri: "https://app.example/cb",
|
|
7785
|
+
response_type: "code",
|
|
7786
|
+
code_challenge: challenge,
|
|
7787
|
+
code_challenge_method: "S256",
|
|
7788
|
+
scope: "vault:admin",
|
|
7789
|
+
resource: `${ISSUER}/vault/jon/mcp`,
|
|
7790
|
+
}),
|
|
7791
|
+
{
|
|
7792
|
+
headers: {
|
|
7793
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
7794
|
+
},
|
|
7795
|
+
},
|
|
7796
|
+
),
|
|
7797
|
+
RESOURCE_DEPS,
|
|
7798
|
+
);
|
|
7799
|
+
// Narrowing turns vault:admin → vault:jon:admin which is NON-requestable;
|
|
7800
|
+
// the gate rejects via redirect with invalid_scope.
|
|
7801
|
+
expect(res.status).toBe(302);
|
|
7802
|
+
const loc = res.headers.get("location") ?? "";
|
|
7803
|
+
expect(loc).toContain("error=invalid_scope");
|
|
7804
|
+
expect(loc).toContain("vault%3Ajon%3Aadmin");
|
|
7805
|
+
} finally {
|
|
7806
|
+
cleanup();
|
|
7807
|
+
}
|
|
7808
|
+
});
|
|
7809
|
+
});
|