@openparachute/hub 0.7.3 → 0.7.4-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/package.json +4 -11
- package/src/__tests__/admin-vaults.test.ts +164 -1
- package/src/__tests__/api-account-2fa.test.ts +381 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/cli.test.ts +22 -0
- package/src/__tests__/clients.test.ts +28 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/init.test.ts +507 -1
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +488 -0
- package/src/__tests__/oauth-ui.test.ts +55 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/status-supervisor.test.ts +152 -3
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-vaults.ts +7 -12
- package/src/api-account-2fa.ts +373 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/cli.ts +49 -5
- package/src/clients.ts +14 -0
- package/src/commands/init.ts +224 -37
- package/src/commands/status.ts +108 -5
- package/src/help.ts +17 -0
- package/src/hub-server.ts +72 -7
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +98 -6
- package/src/oauth-ui.ts +123 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/{index-DZzX_Enf.js → index-DygKux-C.js} +13 -13
- package/web/ui/dist/index.html +2 -2
|
@@ -2914,6 +2914,75 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
2914
2914
|
notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
|
|
2915
2915
|
});
|
|
2916
2916
|
});
|
|
2917
|
+
|
|
2918
|
+
// closes #478 — an empty-paths vault row ("installed but no servable
|
|
2919
|
+
// instance"; vault's self-register emits `paths: []` at zero vaults) must
|
|
2920
|
+
// NOT synthesize a phantom `vault` / `vault:default` entry pointing at
|
|
2921
|
+
// root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
|
|
2922
|
+
// resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
|
|
2923
|
+
// `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
|
|
2924
|
+
// admin-vaults.ts / vault-names.ts.
|
|
2925
|
+
test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
|
|
2926
|
+
const emptyPathsManifest: ServicesManifest = {
|
|
2927
|
+
services: [
|
|
2928
|
+
{
|
|
2929
|
+
name: "parachute-vault",
|
|
2930
|
+
port: 1940,
|
|
2931
|
+
paths: [],
|
|
2932
|
+
health: "/vault/default/health",
|
|
2933
|
+
version: "0.7.0",
|
|
2934
|
+
},
|
|
2935
|
+
],
|
|
2936
|
+
};
|
|
2937
|
+
// Broad scope: would have leaked `vault` + `vault:default` at `/`.
|
|
2938
|
+
expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
|
|
2939
|
+
// Per-vault-narrowed scope for the phantom name: also nothing.
|
|
2940
|
+
expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
|
|
2944
|
+
const realManifest: ServicesManifest = {
|
|
2945
|
+
services: [
|
|
2946
|
+
{
|
|
2947
|
+
name: "parachute-vault",
|
|
2948
|
+
port: 1940,
|
|
2949
|
+
paths: ["/vault/default"],
|
|
2950
|
+
health: "/vault/default/health",
|
|
2951
|
+
version: "0.7.0",
|
|
2952
|
+
},
|
|
2953
|
+
],
|
|
2954
|
+
};
|
|
2955
|
+
expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
|
|
2956
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
|
|
2957
|
+
});
|
|
2958
|
+
});
|
|
2959
|
+
|
|
2960
|
+
test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
|
|
2961
|
+
// A transitional manifest could carry both a path-less bare row and a
|
|
2962
|
+
// real instance row. The empty-paths row must contribute nothing; the
|
|
2963
|
+
// real vault is unaffected.
|
|
2964
|
+
const mixedManifest: ServicesManifest = {
|
|
2965
|
+
services: [
|
|
2966
|
+
{
|
|
2967
|
+
name: "parachute-vault",
|
|
2968
|
+
port: 1940,
|
|
2969
|
+
paths: [],
|
|
2970
|
+
health: "/vault/default/health",
|
|
2971
|
+
version: "0.7.0",
|
|
2972
|
+
},
|
|
2973
|
+
{
|
|
2974
|
+
name: "parachute-vault-work",
|
|
2975
|
+
port: 1941,
|
|
2976
|
+
paths: ["/vault/work"],
|
|
2977
|
+
health: "/vault/work/health",
|
|
2978
|
+
version: "0.7.0",
|
|
2979
|
+
},
|
|
2980
|
+
],
|
|
2981
|
+
};
|
|
2982
|
+
expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
|
|
2983
|
+
vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
|
|
2984
|
+
});
|
|
2985
|
+
});
|
|
2917
2986
|
});
|
|
2918
2987
|
});
|
|
2919
2988
|
|
|
@@ -9899,3 +9968,422 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
9899
9968
|
}
|
|
9900
9969
|
});
|
|
9901
9970
|
});
|
|
9971
|
+
|
|
9972
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
9973
|
+
// hub#689 — owner-on-own-vault VERB SELECTOR. The consent screen offers an
|
|
9974
|
+
// owner of the picked vault a read/write/admin selector (pre-selected to
|
|
9975
|
+
// admin) when the client requested an UNNAMED `vault:read`/`vault:write`. On
|
|
9976
|
+
// submit, the owner's selection widens the unnamed verb to the chosen level
|
|
9977
|
+
// on the picked vault — BEFORE `capScopesToUserAuthority`, which remains the
|
|
9978
|
+
// backstop. The selector value is an UNTRUSTED hint: the handler re-derives
|
|
9979
|
+
// ownership of the picked vault server-side, and the cap drops any verb the
|
|
9980
|
+
// user doesn't actually hold.
|
|
9981
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
9982
|
+
describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
|
|
9983
|
+
const TTL_S = Math.floor(SESSION_TTL_MS / 1000);
|
|
9984
|
+
const SEL_MANIFEST: ServicesManifest = {
|
|
9985
|
+
services: [
|
|
9986
|
+
{
|
|
9987
|
+
name: "parachute-vault",
|
|
9988
|
+
port: 1940,
|
|
9989
|
+
paths: ["/vault/work", "/vault/other"],
|
|
9990
|
+
health: "/health",
|
|
9991
|
+
version: "0.7.0",
|
|
9992
|
+
},
|
|
9993
|
+
],
|
|
9994
|
+
};
|
|
9995
|
+
const selDeps = {
|
|
9996
|
+
issuer: ISSUER,
|
|
9997
|
+
loadServicesManifest: () => SEL_MANIFEST,
|
|
9998
|
+
hubBoundOrigins: () => [ISSUER],
|
|
9999
|
+
};
|
|
10000
|
+
|
|
10001
|
+
async function submitConsent(
|
|
10002
|
+
db: Awaited<ReturnType<typeof makeDb>>["db"],
|
|
10003
|
+
sessionId: string,
|
|
10004
|
+
clientId: string,
|
|
10005
|
+
scope: string,
|
|
10006
|
+
challenge: string,
|
|
10007
|
+
extra: Record<string, string> = {},
|
|
10008
|
+
): Promise<Response> {
|
|
10009
|
+
const form = new URLSearchParams({
|
|
10010
|
+
__action: "consent",
|
|
10011
|
+
__csrf: TEST_CSRF,
|
|
10012
|
+
approve: "yes",
|
|
10013
|
+
client_id: clientId,
|
|
10014
|
+
redirect_uri: "https://app.example/cb",
|
|
10015
|
+
response_type: "code",
|
|
10016
|
+
scope,
|
|
10017
|
+
code_challenge: challenge,
|
|
10018
|
+
code_challenge_method: "S256",
|
|
10019
|
+
...extra,
|
|
10020
|
+
});
|
|
10021
|
+
return handleAuthorizePost(
|
|
10022
|
+
db,
|
|
10023
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
10024
|
+
method: "POST",
|
|
10025
|
+
body: form,
|
|
10026
|
+
headers: {
|
|
10027
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
10028
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, TTL_S)}`,
|
|
10029
|
+
},
|
|
10030
|
+
}),
|
|
10031
|
+
selDeps,
|
|
10032
|
+
);
|
|
10033
|
+
}
|
|
10034
|
+
|
|
10035
|
+
async function redeemScope(
|
|
10036
|
+
db: Awaited<ReturnType<typeof makeDb>>["db"],
|
|
10037
|
+
code: string,
|
|
10038
|
+
clientId: string,
|
|
10039
|
+
verifier: string,
|
|
10040
|
+
): Promise<string> {
|
|
10041
|
+
const tokenRes = await handleToken(
|
|
10042
|
+
db,
|
|
10043
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
10044
|
+
method: "POST",
|
|
10045
|
+
body: new URLSearchParams({
|
|
10046
|
+
grant_type: "authorization_code",
|
|
10047
|
+
code,
|
|
10048
|
+
client_id: clientId,
|
|
10049
|
+
redirect_uri: "https://app.example/cb",
|
|
10050
|
+
code_verifier: verifier,
|
|
10051
|
+
}),
|
|
10052
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
10053
|
+
}),
|
|
10054
|
+
selDeps,
|
|
10055
|
+
);
|
|
10056
|
+
expect(tokenRes.status).toBe(200);
|
|
10057
|
+
const body = (await tokenRes.json()) as { scope: string };
|
|
10058
|
+
return body.scope;
|
|
10059
|
+
}
|
|
10060
|
+
|
|
10061
|
+
// GET render: owner of the picked vault sees the selector. A non-admin
|
|
10062
|
+
// assigned to exactly one vault gets the locked picker → the selector is
|
|
10063
|
+
// offered (they hold admin on their assigned vault).
|
|
10064
|
+
test("selector RENDERED for an owner (assigned user) of the picked vault", async () => {
|
|
10065
|
+
const { db, cleanup } = await makeDb();
|
|
10066
|
+
try {
|
|
10067
|
+
await createUser(db, "owner", "pw"); // consumes the admin slot
|
|
10068
|
+
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
10069
|
+
setUserVaults(db, friend.id, ["work"]); // role=write → holds admin on "work"
|
|
10070
|
+
const session = createSession(db, { userId: friend.id });
|
|
10071
|
+
const reg = registerClient(db, {
|
|
10072
|
+
redirectUris: ["https://app.example/cb"],
|
|
10073
|
+
status: "approved",
|
|
10074
|
+
});
|
|
10075
|
+
const { challenge } = makePkce();
|
|
10076
|
+
const res = handleAuthorizeGet(
|
|
10077
|
+
db,
|
|
10078
|
+
new Request(
|
|
10079
|
+
authorizeUrl({
|
|
10080
|
+
client_id: reg.client.clientId,
|
|
10081
|
+
redirect_uri: "https://app.example/cb",
|
|
10082
|
+
response_type: "code",
|
|
10083
|
+
code_challenge: challenge,
|
|
10084
|
+
code_challenge_method: "S256",
|
|
10085
|
+
scope: "vault:read",
|
|
10086
|
+
}),
|
|
10087
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10088
|
+
),
|
|
10089
|
+
selDeps,
|
|
10090
|
+
);
|
|
10091
|
+
expect(res.status).toBe(200);
|
|
10092
|
+
const html = await res.text();
|
|
10093
|
+
expect(html).toContain("Access level");
|
|
10094
|
+
expect(html).toContain('name="verb_select"');
|
|
10095
|
+
// Admin pre-selected, still visibly flagged.
|
|
10096
|
+
expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
|
|
10097
|
+
expect(html).toContain("badge-admin");
|
|
10098
|
+
} finally {
|
|
10099
|
+
cleanup();
|
|
10100
|
+
}
|
|
10101
|
+
});
|
|
10102
|
+
|
|
10103
|
+
// GET render: a read-only-assigned user (role=read → holds read, NOT admin)
|
|
10104
|
+
// does NOT see the selector — offering admin pre-selected would promise an
|
|
10105
|
+
// upgrade the cap silently demotes. They hold the vault but not admin on it.
|
|
10106
|
+
test("selector NOT rendered for a read-only-assigned user (holds read, not admin)", async () => {
|
|
10107
|
+
const { db, cleanup } = await makeDb();
|
|
10108
|
+
try {
|
|
10109
|
+
await createUser(db, "owner", "pw");
|
|
10110
|
+
const reader = await createUser(db, "reader", "pw", { allowMulti: true });
|
|
10111
|
+
// role=read directly (setUserVaults hardcodes write) → holds read only.
|
|
10112
|
+
db.prepare(
|
|
10113
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10114
|
+
).run(reader.id, "work", new Date().toISOString());
|
|
10115
|
+
const session = createSession(db, { userId: reader.id });
|
|
10116
|
+
const reg = registerClient(db, {
|
|
10117
|
+
redirectUris: ["https://app.example/cb"],
|
|
10118
|
+
status: "approved",
|
|
10119
|
+
});
|
|
10120
|
+
const { challenge } = makePkce();
|
|
10121
|
+
const res = handleAuthorizeGet(
|
|
10122
|
+
db,
|
|
10123
|
+
new Request(
|
|
10124
|
+
authorizeUrl({
|
|
10125
|
+
client_id: reg.client.clientId,
|
|
10126
|
+
redirect_uri: "https://app.example/cb",
|
|
10127
|
+
response_type: "code",
|
|
10128
|
+
code_challenge: challenge,
|
|
10129
|
+
code_challenge_method: "S256",
|
|
10130
|
+
scope: "vault:read",
|
|
10131
|
+
}),
|
|
10132
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10133
|
+
),
|
|
10134
|
+
selDeps,
|
|
10135
|
+
);
|
|
10136
|
+
expect(res.status).toBe(200);
|
|
10137
|
+
const html = await res.text();
|
|
10138
|
+
expect(html).not.toContain("Access level");
|
|
10139
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10140
|
+
} finally {
|
|
10141
|
+
cleanup();
|
|
10142
|
+
}
|
|
10143
|
+
});
|
|
10144
|
+
|
|
10145
|
+
// GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
|
|
10146
|
+
// see the selector — they can't authorize a vault scope at all.
|
|
10147
|
+
test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
|
|
10148
|
+
const { db, cleanup } = await makeDb();
|
|
10149
|
+
try {
|
|
10150
|
+
await createUser(db, "owner", "pw");
|
|
10151
|
+
const stranger = await createUser(db, "stranger", "pw", { allowMulti: true });
|
|
10152
|
+
// No setUserVaults → zero assignments → not an owner of anything.
|
|
10153
|
+
const session = createSession(db, { userId: stranger.id });
|
|
10154
|
+
const reg = registerClient(db, {
|
|
10155
|
+
redirectUris: ["https://app.example/cb"],
|
|
10156
|
+
status: "approved",
|
|
10157
|
+
});
|
|
10158
|
+
const { challenge } = makePkce();
|
|
10159
|
+
const res = handleAuthorizeGet(
|
|
10160
|
+
db,
|
|
10161
|
+
new Request(
|
|
10162
|
+
authorizeUrl({
|
|
10163
|
+
client_id: reg.client.clientId,
|
|
10164
|
+
redirect_uri: "https://app.example/cb",
|
|
10165
|
+
response_type: "code",
|
|
10166
|
+
code_challenge: challenge,
|
|
10167
|
+
code_challenge_method: "S256",
|
|
10168
|
+
scope: "vault:read",
|
|
10169
|
+
}),
|
|
10170
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10171
|
+
),
|
|
10172
|
+
selDeps,
|
|
10173
|
+
);
|
|
10174
|
+
expect(res.status).toBe(200);
|
|
10175
|
+
const html = await res.text();
|
|
10176
|
+
expect(html).not.toContain("Access level");
|
|
10177
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10178
|
+
} finally {
|
|
10179
|
+
cleanup();
|
|
10180
|
+
}
|
|
10181
|
+
});
|
|
10182
|
+
|
|
10183
|
+
// Submit: owner (first admin) + client requested unnamed vault:read + selects
|
|
10184
|
+
// admin → minted vault:<picked>:admin. THE core bug fix.
|
|
10185
|
+
test("owner selects admin on an unnamed vault:read → minted vault:work:admin", async () => {
|
|
10186
|
+
const { db, cleanup } = await makeDb();
|
|
10187
|
+
try {
|
|
10188
|
+
const owner = await createUser(db, "owner", "pw"); // first admin
|
|
10189
|
+
const session = createSession(db, { userId: owner.id });
|
|
10190
|
+
const reg = registerClient(db, {
|
|
10191
|
+
redirectUris: ["https://app.example/cb"],
|
|
10192
|
+
status: "approved",
|
|
10193
|
+
});
|
|
10194
|
+
const { verifier, challenge } = makePkce();
|
|
10195
|
+
const res = await submitConsent(
|
|
10196
|
+
db,
|
|
10197
|
+
session.id,
|
|
10198
|
+
reg.client.clientId,
|
|
10199
|
+
"vault:read",
|
|
10200
|
+
challenge,
|
|
10201
|
+
{
|
|
10202
|
+
vault_pick: "work",
|
|
10203
|
+
verb_select: "admin",
|
|
10204
|
+
},
|
|
10205
|
+
);
|
|
10206
|
+
expect(res.status).toBe(302);
|
|
10207
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10208
|
+
expect(code).toBeTruthy();
|
|
10209
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10210
|
+
expect(scope).toBe("vault:work:admin");
|
|
10211
|
+
} finally {
|
|
10212
|
+
cleanup();
|
|
10213
|
+
}
|
|
10214
|
+
});
|
|
10215
|
+
|
|
10216
|
+
// Submit: owner selects write → vault:<picked>:write.
|
|
10217
|
+
test("owner selects write on an unnamed vault:read → minted vault:work:write", async () => {
|
|
10218
|
+
const { db, cleanup } = await makeDb();
|
|
10219
|
+
try {
|
|
10220
|
+
const owner = await createUser(db, "owner", "pw");
|
|
10221
|
+
const session = createSession(db, { userId: owner.id });
|
|
10222
|
+
const reg = registerClient(db, {
|
|
10223
|
+
redirectUris: ["https://app.example/cb"],
|
|
10224
|
+
status: "approved",
|
|
10225
|
+
});
|
|
10226
|
+
const { verifier, challenge } = makePkce();
|
|
10227
|
+
const res = await submitConsent(
|
|
10228
|
+
db,
|
|
10229
|
+
session.id,
|
|
10230
|
+
reg.client.clientId,
|
|
10231
|
+
"vault:read",
|
|
10232
|
+
challenge,
|
|
10233
|
+
{
|
|
10234
|
+
vault_pick: "work",
|
|
10235
|
+
verb_select: "write",
|
|
10236
|
+
},
|
|
10237
|
+
);
|
|
10238
|
+
expect(res.status).toBe(302);
|
|
10239
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10240
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10241
|
+
expect(scope).toBe("vault:work:write");
|
|
10242
|
+
} finally {
|
|
10243
|
+
cleanup();
|
|
10244
|
+
}
|
|
10245
|
+
});
|
|
10246
|
+
|
|
10247
|
+
// Submit: owner DOWNGRADES — selects read on an unnamed vault:write → read.
|
|
10248
|
+
test("owner selects read on an unnamed vault:write → minted vault:work:read (downgrade)", async () => {
|
|
10249
|
+
const { db, cleanup } = await makeDb();
|
|
10250
|
+
try {
|
|
10251
|
+
const owner = await createUser(db, "owner", "pw");
|
|
10252
|
+
const session = createSession(db, { userId: owner.id });
|
|
10253
|
+
const reg = registerClient(db, {
|
|
10254
|
+
redirectUris: ["https://app.example/cb"],
|
|
10255
|
+
status: "approved",
|
|
10256
|
+
});
|
|
10257
|
+
const { verifier, challenge } = makePkce();
|
|
10258
|
+
const res = await submitConsent(
|
|
10259
|
+
db,
|
|
10260
|
+
session.id,
|
|
10261
|
+
reg.client.clientId,
|
|
10262
|
+
"vault:write",
|
|
10263
|
+
challenge,
|
|
10264
|
+
{
|
|
10265
|
+
vault_pick: "work",
|
|
10266
|
+
verb_select: "read",
|
|
10267
|
+
},
|
|
10268
|
+
);
|
|
10269
|
+
expect(res.status).toBe(302);
|
|
10270
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10271
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10272
|
+
expect(scope).toBe("vault:work:read");
|
|
10273
|
+
} finally {
|
|
10274
|
+
cleanup();
|
|
10275
|
+
}
|
|
10276
|
+
});
|
|
10277
|
+
|
|
10278
|
+
// SECURITY: a non-owner who holds only READ on the picked vault forges
|
|
10279
|
+
// verb_select=admin → the server re-derives ownership (no admin held) and
|
|
10280
|
+
// refuses to widen; the cap is the backstop. Minted scope is capped to
|
|
10281
|
+
// their actual authority (read), NOT elevated to admin.
|
|
10282
|
+
test("SECURITY: read-only-assigned non-owner forges verb_select=admin → minted vault:work:read, NOT admin", async () => {
|
|
10283
|
+
const { db, cleanup } = await makeDb();
|
|
10284
|
+
try {
|
|
10285
|
+
await createUser(db, "owner", "pw"); // first admin = owner
|
|
10286
|
+
const reader = await createUser(db, "reader", "pw", { allowMulti: true });
|
|
10287
|
+
// Assign "work" with role=read directly → holds read only (NOT admin).
|
|
10288
|
+
// setUserVaults hardcodes role=write, so insert the read row by hand to
|
|
10289
|
+
// construct the read-only-authority case the cap must defend.
|
|
10290
|
+
db.prepare(
|
|
10291
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10292
|
+
).run(reader.id, "work", new Date().toISOString());
|
|
10293
|
+
const session = createSession(db, { userId: reader.id });
|
|
10294
|
+
const reg = registerClient(db, {
|
|
10295
|
+
redirectUris: ["https://app.example/cb"],
|
|
10296
|
+
status: "approved",
|
|
10297
|
+
});
|
|
10298
|
+
const { verifier, challenge } = makePkce();
|
|
10299
|
+
const res = await submitConsent(
|
|
10300
|
+
db,
|
|
10301
|
+
session.id,
|
|
10302
|
+
reg.client.clientId,
|
|
10303
|
+
"vault:read",
|
|
10304
|
+
challenge,
|
|
10305
|
+
{
|
|
10306
|
+
vault_pick: "work",
|
|
10307
|
+
verb_select: "admin", // FORGED — reader holds read only
|
|
10308
|
+
},
|
|
10309
|
+
);
|
|
10310
|
+
// Read survives (held); admin never rides along.
|
|
10311
|
+
expect(res.status).toBe(302);
|
|
10312
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10313
|
+
expect(code).toBeTruthy();
|
|
10314
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10315
|
+
expect(scope).toBe("vault:work:read");
|
|
10316
|
+
expect(scope).not.toContain("admin");
|
|
10317
|
+
// And the recorded grant carries no admin verb either.
|
|
10318
|
+
const grant = findGrant(db, reader.id, reg.client.clientId);
|
|
10319
|
+
expect(grant?.scopes ?? []).not.toContain("vault:work:admin");
|
|
10320
|
+
} finally {
|
|
10321
|
+
cleanup();
|
|
10322
|
+
}
|
|
10323
|
+
});
|
|
10324
|
+
|
|
10325
|
+
// SECURITY: a non-admin assigned to "work" picks/forges admin on "other"
|
|
10326
|
+
// (a vault outside their assignment) — the assignment-mismatch gate refuses
|
|
10327
|
+
// before widening ever runs. No token minted.
|
|
10328
|
+
test("SECURITY: forged verb_select=admin against an UNASSIGNED vault → 400 (mismatch gate, no mint)", async () => {
|
|
10329
|
+
const { db, cleanup } = await makeDb();
|
|
10330
|
+
try {
|
|
10331
|
+
await createUser(db, "owner", "pw");
|
|
10332
|
+
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
10333
|
+
setUserVaults(db, friend.id, ["work"]); // assigned "work" only
|
|
10334
|
+
const session = createSession(db, { userId: friend.id });
|
|
10335
|
+
const reg = registerClient(db, {
|
|
10336
|
+
redirectUris: ["https://app.example/cb"],
|
|
10337
|
+
status: "approved",
|
|
10338
|
+
});
|
|
10339
|
+
const { challenge } = makePkce();
|
|
10340
|
+
const res = await submitConsent(
|
|
10341
|
+
db,
|
|
10342
|
+
session.id,
|
|
10343
|
+
reg.client.clientId,
|
|
10344
|
+
"vault:read",
|
|
10345
|
+
challenge,
|
|
10346
|
+
{
|
|
10347
|
+
vault_pick: "other", // NOT in friend's assignment
|
|
10348
|
+
verb_select: "admin",
|
|
10349
|
+
},
|
|
10350
|
+
);
|
|
10351
|
+
expect(res.status).toBe(400);
|
|
10352
|
+
expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
|
|
10353
|
+
} finally {
|
|
10354
|
+
cleanup();
|
|
10355
|
+
}
|
|
10356
|
+
});
|
|
10357
|
+
|
|
10358
|
+
// Owner without a verb_select field (older form / JS-off) → unchanged
|
|
10359
|
+
// behavior: the unnamed verb narrows as-requested (vault:read → work:read).
|
|
10360
|
+
test("owner with NO verb_select → unchanged narrowing (vault:read → vault:work:read)", async () => {
|
|
10361
|
+
const { db, cleanup } = await makeDb();
|
|
10362
|
+
try {
|
|
10363
|
+
const owner = await createUser(db, "owner", "pw");
|
|
10364
|
+
const session = createSession(db, { userId: owner.id });
|
|
10365
|
+
const reg = registerClient(db, {
|
|
10366
|
+
redirectUris: ["https://app.example/cb"],
|
|
10367
|
+
status: "approved",
|
|
10368
|
+
});
|
|
10369
|
+
const { verifier, challenge } = makePkce();
|
|
10370
|
+
const res = await submitConsent(
|
|
10371
|
+
db,
|
|
10372
|
+
session.id,
|
|
10373
|
+
reg.client.clientId,
|
|
10374
|
+
"vault:read",
|
|
10375
|
+
challenge,
|
|
10376
|
+
{
|
|
10377
|
+
vault_pick: "work",
|
|
10378
|
+
// no verb_select
|
|
10379
|
+
},
|
|
10380
|
+
);
|
|
10381
|
+
expect(res.status).toBe(302);
|
|
10382
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10383
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10384
|
+
expect(scope).toBe("vault:work:read");
|
|
10385
|
+
} finally {
|
|
10386
|
+
cleanup();
|
|
10387
|
+
}
|
|
10388
|
+
});
|
|
10389
|
+
});
|
|
@@ -116,7 +116,8 @@ describe("renderConsent", () => {
|
|
|
116
116
|
expect(html).toContain("vault:admin");
|
|
117
117
|
// Scope explanations from the registry
|
|
118
118
|
expect(html).toContain("Read your notes");
|
|
119
|
-
|
|
119
|
+
// hub#689 Leg 1: the admin label now enumerates the concrete grants.
|
|
120
|
+
expect(html).toContain("Read and write everything, plus admin");
|
|
120
121
|
});
|
|
121
122
|
|
|
122
123
|
test("highlights admin scopes with a danger color and badge", () => {
|
|
@@ -252,6 +253,59 @@ describe("renderConsent", () => {
|
|
|
252
253
|
expect(html).not.toContain("You have no assigned vaults");
|
|
253
254
|
expect(html).not.toContain('value="yes" class="btn btn-primary" disabled');
|
|
254
255
|
});
|
|
256
|
+
|
|
257
|
+
// hub#689 — owner-on-own-vault verb selector rendering.
|
|
258
|
+
test("renders the owner verb selector (read/write/admin), pre-selected to admin", () => {
|
|
259
|
+
const html = renderConsent({
|
|
260
|
+
params: { ...PARAMS, scope: "vault:read" },
|
|
261
|
+
csrfToken: CSRF,
|
|
262
|
+
clientId: "c",
|
|
263
|
+
clientName: "App",
|
|
264
|
+
scopes: ["vault:read"],
|
|
265
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
|
|
266
|
+
ownerVerbSelector: { requestedVerbs: ["read"] },
|
|
267
|
+
});
|
|
268
|
+
expect(html).toContain("Access level");
|
|
269
|
+
expect(html).toContain('name="verb_select" value="read"');
|
|
270
|
+
expect(html).toContain('name="verb_select" value="write"');
|
|
271
|
+
expect(html).toContain('name="verb_select" value="admin"');
|
|
272
|
+
// Admin is the pre-selected (checked) option.
|
|
273
|
+
expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
|
|
274
|
+
// read/write are NOT pre-checked.
|
|
275
|
+
expect(html).not.toMatch(/name="verb_select" value="read"[^>]*checked/);
|
|
276
|
+
expect(html).not.toMatch(/name="verb_select" value="write"[^>]*checked/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("owner verb selector keeps the admin option visibly flagged (admin badge + red border)", () => {
|
|
280
|
+
const html = renderConsent({
|
|
281
|
+
params: { ...PARAMS, scope: "vault:read" },
|
|
282
|
+
csrfToken: CSRF,
|
|
283
|
+
clientId: "c",
|
|
284
|
+
clientName: "App",
|
|
285
|
+
scopes: ["vault:read"],
|
|
286
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
|
|
287
|
+
ownerVerbSelector: { requestedVerbs: ["read"] },
|
|
288
|
+
});
|
|
289
|
+
// The .scope-admin red-border class + the admin badge ride on the admin
|
|
290
|
+
// radio option so a pre-selected admin grant stays transparent.
|
|
291
|
+
expect(html).toContain("verb-option-admin");
|
|
292
|
+
expect(html).toContain("scope-admin");
|
|
293
|
+
expect(html).toContain("badge-admin");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("does NOT render the verb selector when ownerVerbSelector is absent (non-owner)", () => {
|
|
297
|
+
const html = renderConsent({
|
|
298
|
+
params: { ...PARAMS, scope: "vault:read" },
|
|
299
|
+
csrfToken: CSRF,
|
|
300
|
+
clientId: "c",
|
|
301
|
+
clientName: "App",
|
|
302
|
+
scopes: ["vault:read"],
|
|
303
|
+
vaultPicker: { unnamedVerbs: ["read"], availableVaults: ["work"], lockedVault: "work" },
|
|
304
|
+
// ownerVerbSelector omitted → no selector
|
|
305
|
+
});
|
|
306
|
+
expect(html).not.toContain("Access level");
|
|
307
|
+
expect(html).not.toContain('name="verb_select"');
|
|
308
|
+
});
|
|
255
309
|
});
|
|
256
310
|
|
|
257
311
|
describe("renderError", () => {
|
|
@@ -29,6 +29,25 @@ describe("SCOPE_EXPLANATIONS", () => {
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
// hub#689 Leg 1: the vault:admin consent copy must enumerate what
|
|
33
|
+
// admin actually grants (config/settings, triggers/automation, GitHub
|
|
34
|
+
// backup, token minting) on top of read/write — so the consent screen
|
|
35
|
+
// is honest about the admin blast radius, not a vague "configuration
|
|
36
|
+
// changes" hand-wave.
|
|
37
|
+
test("vault:admin label enumerates the concrete admin grants (hub#689 Leg 1)", () => {
|
|
38
|
+
const label = SCOPE_EXPLANATIONS["vault:admin"]?.label ?? "";
|
|
39
|
+
const lower = label.toLowerCase();
|
|
40
|
+
expect(SCOPE_EXPLANATIONS["vault:admin"]?.level).toBe("admin");
|
|
41
|
+
// Read + write are still part of what admin grants.
|
|
42
|
+
expect(lower).toContain("read");
|
|
43
|
+
expect(lower).toContain("write");
|
|
44
|
+
// The four enumerated admin powers.
|
|
45
|
+
expect(lower).toContain("config");
|
|
46
|
+
expect(lower).toContain("trigger");
|
|
47
|
+
expect(lower).toContain("github");
|
|
48
|
+
expect(lower).toContain("token");
|
|
49
|
+
});
|
|
50
|
+
|
|
32
51
|
test("FIRST_PARTY_SCOPES is sorted and matches the keys of SCOPE_EXPLANATIONS", () => {
|
|
33
52
|
expect(FIRST_PARTY_SCOPES).toEqual([...FIRST_PARTY_SCOPES].sort());
|
|
34
53
|
expect(new Set(FIRST_PARTY_SCOPES)).toEqual(new Set(Object.keys(SCOPE_EXPLANATIONS)));
|