@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.20
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-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +52 -25
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/api-me.ts +11 -2
- package/src/api-modules.ts +105 -0
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/cli.ts +56 -5
- package/src/clients.ts +178 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +139 -24
- package/src/hub-settings.ts +163 -1
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +103 -6
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -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-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -353,6 +353,88 @@ describe("handleAuthorizeGet", () => {
|
|
|
353
353
|
}
|
|
354
354
|
});
|
|
355
355
|
|
|
356
|
+
// hub#314 — same-hub vs external trust marker reaches the rendered consent
|
|
357
|
+
// screen via `client.sameHub`. An unnamed `vault:read` request from a
|
|
358
|
+
// same-hub client falls through to consent (the auto-approve gate requires
|
|
359
|
+
// `!hasUnnamedVault`), so we can assert the marker on the GET render.
|
|
360
|
+
test("renders the EXTERNAL trust marker for a third-party DCR client", async () => {
|
|
361
|
+
const { db, cleanup } = await makeDb();
|
|
362
|
+
try {
|
|
363
|
+
const user = await createUser(db, "owner", "pw");
|
|
364
|
+
const session = createSession(db, { userId: user.id });
|
|
365
|
+
// registerClient defaults sameHub:false → external (third-party DCR).
|
|
366
|
+
const reg = registerClient(db, {
|
|
367
|
+
redirectUris: ["https://app.example/cb"],
|
|
368
|
+
clientName: "ThirdPartyApp",
|
|
369
|
+
});
|
|
370
|
+
const { challenge } = makePkce();
|
|
371
|
+
const req = new Request(
|
|
372
|
+
authorizeUrl({
|
|
373
|
+
client_id: reg.client.clientId,
|
|
374
|
+
redirect_uri: "https://app.example/cb",
|
|
375
|
+
response_type: "code",
|
|
376
|
+
code_challenge: challenge,
|
|
377
|
+
code_challenge_method: "S256",
|
|
378
|
+
scope: "vault:read",
|
|
379
|
+
}),
|
|
380
|
+
{
|
|
381
|
+
headers: {
|
|
382
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
387
|
+
expect(res.status).toBe(200);
|
|
388
|
+
const html = await res.text();
|
|
389
|
+
// Element form — the `.badge-trust-*` CSS class names are always in the
|
|
390
|
+
// inlined <style>; the rendered ELEMENT only appears when the marker fires.
|
|
391
|
+
expect(html).toContain('class="badge badge-trust-external"');
|
|
392
|
+
expect(html).toContain("third-party app that registered itself");
|
|
393
|
+
expect(html).not.toContain('class="badge badge-trust-same-hub"');
|
|
394
|
+
} finally {
|
|
395
|
+
cleanup();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("renders the FIRST-PARTY trust marker for a same-hub client", async () => {
|
|
400
|
+
const { db, cleanup } = await makeDb();
|
|
401
|
+
try {
|
|
402
|
+
const user = await createUser(db, "owner", "pw");
|
|
403
|
+
const session = createSession(db, { userId: user.id });
|
|
404
|
+
const reg = registerClient(db, {
|
|
405
|
+
redirectUris: ["https://app.example/cb"],
|
|
406
|
+
clientName: "FirstPartyApp",
|
|
407
|
+
sameHub: true,
|
|
408
|
+
});
|
|
409
|
+
const { challenge } = makePkce();
|
|
410
|
+
const req = new Request(
|
|
411
|
+
authorizeUrl({
|
|
412
|
+
client_id: reg.client.clientId,
|
|
413
|
+
redirect_uri: "https://app.example/cb",
|
|
414
|
+
response_type: "code",
|
|
415
|
+
code_challenge: challenge,
|
|
416
|
+
code_challenge_method: "S256",
|
|
417
|
+
// Unnamed vault verb → bypasses the same-hub auto-approve gate
|
|
418
|
+
// (`!hasUnnamedVault`) and falls through to the consent render.
|
|
419
|
+
scope: "vault:read",
|
|
420
|
+
}),
|
|
421
|
+
{
|
|
422
|
+
headers: {
|
|
423
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}`,
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
const res = handleAuthorizeGet(db, req, { issuer: ISSUER });
|
|
428
|
+
expect(res.status).toBe(200);
|
|
429
|
+
const html = await res.text();
|
|
430
|
+
expect(html).toContain('class="badge badge-trust-same-hub"');
|
|
431
|
+
expect(html).toContain("Registered through this hub");
|
|
432
|
+
expect(html).not.toContain('class="badge badge-trust-external"');
|
|
433
|
+
} finally {
|
|
434
|
+
cleanup();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
356
438
|
test("rejects unknown client_id with 400", async () => {
|
|
357
439
|
const { db, cleanup } = await makeDb();
|
|
358
440
|
try {
|
|
@@ -2914,6 +2996,75 @@ describe("handleToken — full OAuth dance", () => {
|
|
|
2914
2996
|
notes: { url: `${ISSUER}/notes`, version: "0.3.0" },
|
|
2915
2997
|
});
|
|
2916
2998
|
});
|
|
2999
|
+
|
|
3000
|
+
// closes #478 — an empty-paths vault row ("installed but no servable
|
|
3001
|
+
// instance"; vault's self-register emits `paths: []` at zero vaults) must
|
|
3002
|
+
// NOT synthesize a phantom `vault` / `vault:default` entry pointing at
|
|
3003
|
+
// root in the /oauth/token services catalog. Pre-fix the `["/"]` fallback
|
|
3004
|
+
// resolved `vaultInstanceNameFor(name, "/")` → "default" and advertised
|
|
3005
|
+
// `${ISSUER}/` as the vault. Mirrors the skip in well-known.ts /
|
|
3006
|
+
// admin-vaults.ts / vault-names.ts.
|
|
3007
|
+
test("empty-paths vault row produces NO catalog entry — no phantom default (#478)", () => {
|
|
3008
|
+
const emptyPathsManifest: ServicesManifest = {
|
|
3009
|
+
services: [
|
|
3010
|
+
{
|
|
3011
|
+
name: "parachute-vault",
|
|
3012
|
+
port: 1940,
|
|
3013
|
+
paths: [],
|
|
3014
|
+
health: "/vault/default/health",
|
|
3015
|
+
version: "0.7.0",
|
|
3016
|
+
},
|
|
3017
|
+
],
|
|
3018
|
+
};
|
|
3019
|
+
// Broad scope: would have leaked `vault` + `vault:default` at `/`.
|
|
3020
|
+
expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:read"])).toEqual({});
|
|
3021
|
+
// Per-vault-narrowed scope for the phantom name: also nothing.
|
|
3022
|
+
expect(buildServicesCatalog(emptyPathsManifest, ISSUER, ["vault:default:read"])).toEqual({});
|
|
3023
|
+
});
|
|
3024
|
+
|
|
3025
|
+
test("positive control: a vault row WITH a path is still cataloged (#478)", () => {
|
|
3026
|
+
const realManifest: ServicesManifest = {
|
|
3027
|
+
services: [
|
|
3028
|
+
{
|
|
3029
|
+
name: "parachute-vault",
|
|
3030
|
+
port: 1940,
|
|
3031
|
+
paths: ["/vault/default"],
|
|
3032
|
+
health: "/vault/default/health",
|
|
3033
|
+
version: "0.7.0",
|
|
3034
|
+
},
|
|
3035
|
+
],
|
|
3036
|
+
};
|
|
3037
|
+
expect(buildServicesCatalog(realManifest, ISSUER, ["vault:read"])).toEqual({
|
|
3038
|
+
vault: { url: `${ISSUER}/vault/default`, version: "0.7.0" },
|
|
3039
|
+
});
|
|
3040
|
+
});
|
|
3041
|
+
|
|
3042
|
+
test("empty-paths vault row alongside a real vault: only the real one is cataloged (#478)", () => {
|
|
3043
|
+
// A transitional manifest could carry both a path-less bare row and a
|
|
3044
|
+
// real instance row. The empty-paths row must contribute nothing; the
|
|
3045
|
+
// real vault is unaffected.
|
|
3046
|
+
const mixedManifest: ServicesManifest = {
|
|
3047
|
+
services: [
|
|
3048
|
+
{
|
|
3049
|
+
name: "parachute-vault",
|
|
3050
|
+
port: 1940,
|
|
3051
|
+
paths: [],
|
|
3052
|
+
health: "/vault/default/health",
|
|
3053
|
+
version: "0.7.0",
|
|
3054
|
+
},
|
|
3055
|
+
{
|
|
3056
|
+
name: "parachute-vault-work",
|
|
3057
|
+
port: 1941,
|
|
3058
|
+
paths: ["/vault/work"],
|
|
3059
|
+
health: "/vault/work/health",
|
|
3060
|
+
version: "0.7.0",
|
|
3061
|
+
},
|
|
3062
|
+
],
|
|
3063
|
+
};
|
|
3064
|
+
expect(buildServicesCatalog(mixedManifest, ISSUER, ["vault:read"])).toEqual({
|
|
3065
|
+
vault: { url: `${ISSUER}/vault/work`, version: "0.7.0" },
|
|
3066
|
+
});
|
|
3067
|
+
});
|
|
2917
3068
|
});
|
|
2918
3069
|
});
|
|
2919
3070
|
|
|
@@ -9899,3 +10050,478 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
|
|
|
9899
10050
|
}
|
|
9900
10051
|
});
|
|
9901
10052
|
});
|
|
10053
|
+
|
|
10054
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
10055
|
+
// hub#689 — owner-on-own-vault VERB SELECTOR. The consent screen offers an
|
|
10056
|
+
// owner of the picked vault a read/write/admin selector (pre-selected to
|
|
10057
|
+
// admin) when the client requested an UNNAMED `vault:read`/`vault:write`. On
|
|
10058
|
+
// submit, the owner's selection widens the unnamed verb to the chosen level
|
|
10059
|
+
// on the picked vault — BEFORE `capScopesToUserAuthority`, which remains the
|
|
10060
|
+
// backstop. The selector value is an UNTRUSTED hint: the handler re-derives
|
|
10061
|
+
// ownership of the picked vault server-side, and the cap drops any verb the
|
|
10062
|
+
// user doesn't actually hold.
|
|
10063
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
10064
|
+
describe("hub#689 — owner-on-own-vault verb selector + widening", () => {
|
|
10065
|
+
const TTL_S = Math.floor(SESSION_TTL_MS / 1000);
|
|
10066
|
+
const SEL_MANIFEST: ServicesManifest = {
|
|
10067
|
+
services: [
|
|
10068
|
+
{
|
|
10069
|
+
name: "parachute-vault",
|
|
10070
|
+
port: 1940,
|
|
10071
|
+
paths: ["/vault/work", "/vault/other"],
|
|
10072
|
+
health: "/health",
|
|
10073
|
+
version: "0.7.0",
|
|
10074
|
+
},
|
|
10075
|
+
],
|
|
10076
|
+
};
|
|
10077
|
+
const selDeps = {
|
|
10078
|
+
issuer: ISSUER,
|
|
10079
|
+
loadServicesManifest: () => SEL_MANIFEST,
|
|
10080
|
+
hubBoundOrigins: () => [ISSUER],
|
|
10081
|
+
};
|
|
10082
|
+
|
|
10083
|
+
async function submitConsent(
|
|
10084
|
+
db: Awaited<ReturnType<typeof makeDb>>["db"],
|
|
10085
|
+
sessionId: string,
|
|
10086
|
+
clientId: string,
|
|
10087
|
+
scope: string,
|
|
10088
|
+
challenge: string,
|
|
10089
|
+
extra: Record<string, string> = {},
|
|
10090
|
+
): Promise<Response> {
|
|
10091
|
+
const form = new URLSearchParams({
|
|
10092
|
+
__action: "consent",
|
|
10093
|
+
__csrf: TEST_CSRF,
|
|
10094
|
+
approve: "yes",
|
|
10095
|
+
client_id: clientId,
|
|
10096
|
+
redirect_uri: "https://app.example/cb",
|
|
10097
|
+
response_type: "code",
|
|
10098
|
+
scope,
|
|
10099
|
+
code_challenge: challenge,
|
|
10100
|
+
code_challenge_method: "S256",
|
|
10101
|
+
...extra,
|
|
10102
|
+
});
|
|
10103
|
+
return handleAuthorizePost(
|
|
10104
|
+
db,
|
|
10105
|
+
new Request(`${ISSUER}/oauth/authorize`, {
|
|
10106
|
+
method: "POST",
|
|
10107
|
+
body: form,
|
|
10108
|
+
headers: {
|
|
10109
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
10110
|
+
cookie: `${CSRF_COOKIE}; ${buildSessionCookie(sessionId, TTL_S)}`,
|
|
10111
|
+
},
|
|
10112
|
+
}),
|
|
10113
|
+
selDeps,
|
|
10114
|
+
);
|
|
10115
|
+
}
|
|
10116
|
+
|
|
10117
|
+
async function redeemScope(
|
|
10118
|
+
db: Awaited<ReturnType<typeof makeDb>>["db"],
|
|
10119
|
+
code: string,
|
|
10120
|
+
clientId: string,
|
|
10121
|
+
verifier: string,
|
|
10122
|
+
): Promise<string> {
|
|
10123
|
+
const tokenRes = await handleToken(
|
|
10124
|
+
db,
|
|
10125
|
+
new Request(`${ISSUER}/oauth/token`, {
|
|
10126
|
+
method: "POST",
|
|
10127
|
+
body: new URLSearchParams({
|
|
10128
|
+
grant_type: "authorization_code",
|
|
10129
|
+
code,
|
|
10130
|
+
client_id: clientId,
|
|
10131
|
+
redirect_uri: "https://app.example/cb",
|
|
10132
|
+
code_verifier: verifier,
|
|
10133
|
+
}),
|
|
10134
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
10135
|
+
}),
|
|
10136
|
+
selDeps,
|
|
10137
|
+
);
|
|
10138
|
+
expect(tokenRes.status).toBe(200);
|
|
10139
|
+
const body = (await tokenRes.json()) as { scope: string };
|
|
10140
|
+
return body.scope;
|
|
10141
|
+
}
|
|
10142
|
+
|
|
10143
|
+
// GET render: owner of the picked vault sees the selector. A non-admin
|
|
10144
|
+
// assigned to exactly one vault gets the locked picker → the selector is
|
|
10145
|
+
// offered (they hold admin on their assigned vault).
|
|
10146
|
+
test("selector RENDERED for an owner (assigned user) of the picked vault", async () => {
|
|
10147
|
+
const { db, cleanup } = await makeDb();
|
|
10148
|
+
try {
|
|
10149
|
+
await createUser(db, "owner", "pw"); // consumes the admin slot
|
|
10150
|
+
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
10151
|
+
setUserVaults(db, friend.id, ["work"]); // role=write → holds admin on "work"
|
|
10152
|
+
const session = createSession(db, { userId: friend.id });
|
|
10153
|
+
const reg = registerClient(db, {
|
|
10154
|
+
redirectUris: ["https://app.example/cb"],
|
|
10155
|
+
status: "approved",
|
|
10156
|
+
});
|
|
10157
|
+
const { challenge } = makePkce();
|
|
10158
|
+
const res = handleAuthorizeGet(
|
|
10159
|
+
db,
|
|
10160
|
+
new Request(
|
|
10161
|
+
authorizeUrl({
|
|
10162
|
+
client_id: reg.client.clientId,
|
|
10163
|
+
redirect_uri: "https://app.example/cb",
|
|
10164
|
+
response_type: "code",
|
|
10165
|
+
code_challenge: challenge,
|
|
10166
|
+
code_challenge_method: "S256",
|
|
10167
|
+
scope: "vault:read",
|
|
10168
|
+
}),
|
|
10169
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10170
|
+
),
|
|
10171
|
+
selDeps,
|
|
10172
|
+
);
|
|
10173
|
+
expect(res.status).toBe(200);
|
|
10174
|
+
const html = await res.text();
|
|
10175
|
+
expect(html).toContain("Access level");
|
|
10176
|
+
expect(html).toContain('name="verb_select"');
|
|
10177
|
+
// Admin pre-selected, still visibly flagged.
|
|
10178
|
+
expect(html).toMatch(/name="verb_select" value="admin"[^>]*checked/);
|
|
10179
|
+
expect(html).toContain("badge-admin");
|
|
10180
|
+
} finally {
|
|
10181
|
+
cleanup();
|
|
10182
|
+
}
|
|
10183
|
+
});
|
|
10184
|
+
|
|
10185
|
+
// GET render: a read-only-assigned user (role=read → holds read, NOT admin)
|
|
10186
|
+
// does NOT see the selector — offering admin pre-selected would promise an
|
|
10187
|
+
// upgrade the cap silently demotes. They hold the vault but not admin on it.
|
|
10188
|
+
test("selector NOT rendered for a read-only-assigned user (holds read, not admin)", async () => {
|
|
10189
|
+
const { db, cleanup } = await makeDb();
|
|
10190
|
+
try {
|
|
10191
|
+
await createUser(db, "owner", "pw");
|
|
10192
|
+
const reader = await createUser(db, "reader", "pw", { allowMulti: true });
|
|
10193
|
+
// role=read directly (setUserVaults hardcodes write) → holds read only.
|
|
10194
|
+
db.prepare(
|
|
10195
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10196
|
+
).run(reader.id, "work", new Date().toISOString());
|
|
10197
|
+
const session = createSession(db, { userId: reader.id });
|
|
10198
|
+
const reg = registerClient(db, {
|
|
10199
|
+
redirectUris: ["https://app.example/cb"],
|
|
10200
|
+
status: "approved",
|
|
10201
|
+
});
|
|
10202
|
+
const { challenge } = makePkce();
|
|
10203
|
+
const res = handleAuthorizeGet(
|
|
10204
|
+
db,
|
|
10205
|
+
new Request(
|
|
10206
|
+
authorizeUrl({
|
|
10207
|
+
client_id: reg.client.clientId,
|
|
10208
|
+
redirect_uri: "https://app.example/cb",
|
|
10209
|
+
response_type: "code",
|
|
10210
|
+
code_challenge: challenge,
|
|
10211
|
+
code_challenge_method: "S256",
|
|
10212
|
+
scope: "vault:read",
|
|
10213
|
+
}),
|
|
10214
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10215
|
+
),
|
|
10216
|
+
selDeps,
|
|
10217
|
+
);
|
|
10218
|
+
expect(res.status).toBe(200);
|
|
10219
|
+
const html = await res.text();
|
|
10220
|
+
expect(html).not.toContain("Access level");
|
|
10221
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10222
|
+
} finally {
|
|
10223
|
+
cleanup();
|
|
10224
|
+
}
|
|
10225
|
+
});
|
|
10226
|
+
|
|
10227
|
+
// GET render (hub#703, folded into hub#314): a user with MIXED authority —
|
|
10228
|
+
// admin on vault A (role=write → holds admin) but only read on vault B
|
|
10229
|
+
// (direct INSERT role=read) — does NOT see the selector. The user could pick
|
|
10230
|
+
// either vault, but doesn't own (hold admin on) EVERY pickable vault, so the
|
|
10231
|
+
// `userHoldsAdminOnPickable` predicate (`assignedVaults.every(v => verbs
|
|
10232
|
+
// includes "admin")`) fails on vault B and the selector is suppressed. The
|
|
10233
|
+
// suppression logic already ships + is correct (oauth-handlers.ts ~2963 +
|
|
10234
|
+
// ~1226); this test closes the coverage gap with no code change.
|
|
10235
|
+
test("selector NOT rendered for a mixed-authority user (admin on A, read-only on B)", async () => {
|
|
10236
|
+
const { db, cleanup } = await makeDb();
|
|
10237
|
+
try {
|
|
10238
|
+
await createUser(db, "owner", "pw");
|
|
10239
|
+
const mixed = await createUser(db, "mixed", "pw", { allowMulti: true });
|
|
10240
|
+
// Vault A ("work"): role=write → vaultVerbsForRole maps to [read,write,
|
|
10241
|
+
// admin], so the user holds admin on A. (setUserVaults hardcodes write.)
|
|
10242
|
+
setUserVaults(db, mixed.id, ["work"]);
|
|
10243
|
+
// Vault B ("other"): direct INSERT role=read → holds read only, NOT admin.
|
|
10244
|
+
// setUserVaults DELETEs first, so this INSERT must come after it to keep A.
|
|
10245
|
+
db.prepare(
|
|
10246
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10247
|
+
).run(mixed.id, "other", new Date().toISOString());
|
|
10248
|
+
const session = createSession(db, { userId: mixed.id });
|
|
10249
|
+
const reg = registerClient(db, {
|
|
10250
|
+
redirectUris: ["https://app.example/cb"],
|
|
10251
|
+
status: "approved",
|
|
10252
|
+
});
|
|
10253
|
+
const { challenge } = makePkce();
|
|
10254
|
+
const res = handleAuthorizeGet(
|
|
10255
|
+
db,
|
|
10256
|
+
new Request(
|
|
10257
|
+
authorizeUrl({
|
|
10258
|
+
client_id: reg.client.clientId,
|
|
10259
|
+
redirect_uri: "https://app.example/cb",
|
|
10260
|
+
response_type: "code",
|
|
10261
|
+
code_challenge: challenge,
|
|
10262
|
+
code_challenge_method: "S256",
|
|
10263
|
+
scope: "vault:read",
|
|
10264
|
+
}),
|
|
10265
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10266
|
+
),
|
|
10267
|
+
selDeps,
|
|
10268
|
+
);
|
|
10269
|
+
expect(res.status).toBe(200);
|
|
10270
|
+
const html = await res.text();
|
|
10271
|
+
// Suppressed: the `.every(v => verbs includes "admin")` check fails on B.
|
|
10272
|
+
expect(html).not.toContain("Access level");
|
|
10273
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10274
|
+
// Sanity: the multi-vault picker DID render (two assigned vaults), so the
|
|
10275
|
+
// suppression is specifically the verb selector, not the whole flow.
|
|
10276
|
+
expect(html).toContain('name="vault_pick" value="work"');
|
|
10277
|
+
expect(html).toContain('name="vault_pick" value="other"');
|
|
10278
|
+
} finally {
|
|
10279
|
+
cleanup();
|
|
10280
|
+
}
|
|
10281
|
+
});
|
|
10282
|
+
|
|
10283
|
+
// GET render: a non-owner (non-admin with ZERO assigned vaults) does NOT
|
|
10284
|
+
// see the selector — they can't authorize a vault scope at all.
|
|
10285
|
+
test("selector NOT rendered for a non-owner (zero-vault non-admin)", async () => {
|
|
10286
|
+
const { db, cleanup } = await makeDb();
|
|
10287
|
+
try {
|
|
10288
|
+
await createUser(db, "owner", "pw");
|
|
10289
|
+
const stranger = await createUser(db, "stranger", "pw", { allowMulti: true });
|
|
10290
|
+
// No setUserVaults → zero assignments → not an owner of anything.
|
|
10291
|
+
const session = createSession(db, { userId: stranger.id });
|
|
10292
|
+
const reg = registerClient(db, {
|
|
10293
|
+
redirectUris: ["https://app.example/cb"],
|
|
10294
|
+
status: "approved",
|
|
10295
|
+
});
|
|
10296
|
+
const { challenge } = makePkce();
|
|
10297
|
+
const res = handleAuthorizeGet(
|
|
10298
|
+
db,
|
|
10299
|
+
new Request(
|
|
10300
|
+
authorizeUrl({
|
|
10301
|
+
client_id: reg.client.clientId,
|
|
10302
|
+
redirect_uri: "https://app.example/cb",
|
|
10303
|
+
response_type: "code",
|
|
10304
|
+
code_challenge: challenge,
|
|
10305
|
+
code_challenge_method: "S256",
|
|
10306
|
+
scope: "vault:read",
|
|
10307
|
+
}),
|
|
10308
|
+
{ headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
|
|
10309
|
+
),
|
|
10310
|
+
selDeps,
|
|
10311
|
+
);
|
|
10312
|
+
expect(res.status).toBe(200);
|
|
10313
|
+
const html = await res.text();
|
|
10314
|
+
expect(html).not.toContain("Access level");
|
|
10315
|
+
expect(html).not.toContain('name="verb_select"');
|
|
10316
|
+
} finally {
|
|
10317
|
+
cleanup();
|
|
10318
|
+
}
|
|
10319
|
+
});
|
|
10320
|
+
|
|
10321
|
+
// Submit: owner (first admin) + client requested unnamed vault:read + selects
|
|
10322
|
+
// admin → minted vault:<picked>:admin. THE core bug fix.
|
|
10323
|
+
test("owner selects admin on an unnamed vault:read → minted vault:work:admin", async () => {
|
|
10324
|
+
const { db, cleanup } = await makeDb();
|
|
10325
|
+
try {
|
|
10326
|
+
const owner = await createUser(db, "owner", "pw"); // first admin
|
|
10327
|
+
const session = createSession(db, { userId: owner.id });
|
|
10328
|
+
const reg = registerClient(db, {
|
|
10329
|
+
redirectUris: ["https://app.example/cb"],
|
|
10330
|
+
status: "approved",
|
|
10331
|
+
});
|
|
10332
|
+
const { verifier, challenge } = makePkce();
|
|
10333
|
+
const res = await submitConsent(
|
|
10334
|
+
db,
|
|
10335
|
+
session.id,
|
|
10336
|
+
reg.client.clientId,
|
|
10337
|
+
"vault:read",
|
|
10338
|
+
challenge,
|
|
10339
|
+
{
|
|
10340
|
+
vault_pick: "work",
|
|
10341
|
+
verb_select: "admin",
|
|
10342
|
+
},
|
|
10343
|
+
);
|
|
10344
|
+
expect(res.status).toBe(302);
|
|
10345
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10346
|
+
expect(code).toBeTruthy();
|
|
10347
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10348
|
+
expect(scope).toBe("vault:work:admin");
|
|
10349
|
+
} finally {
|
|
10350
|
+
cleanup();
|
|
10351
|
+
}
|
|
10352
|
+
});
|
|
10353
|
+
|
|
10354
|
+
// Submit: owner selects write → vault:<picked>:write.
|
|
10355
|
+
test("owner selects write on an unnamed vault:read → minted vault:work:write", async () => {
|
|
10356
|
+
const { db, cleanup } = await makeDb();
|
|
10357
|
+
try {
|
|
10358
|
+
const owner = await createUser(db, "owner", "pw");
|
|
10359
|
+
const session = createSession(db, { userId: owner.id });
|
|
10360
|
+
const reg = registerClient(db, {
|
|
10361
|
+
redirectUris: ["https://app.example/cb"],
|
|
10362
|
+
status: "approved",
|
|
10363
|
+
});
|
|
10364
|
+
const { verifier, challenge } = makePkce();
|
|
10365
|
+
const res = await submitConsent(
|
|
10366
|
+
db,
|
|
10367
|
+
session.id,
|
|
10368
|
+
reg.client.clientId,
|
|
10369
|
+
"vault:read",
|
|
10370
|
+
challenge,
|
|
10371
|
+
{
|
|
10372
|
+
vault_pick: "work",
|
|
10373
|
+
verb_select: "write",
|
|
10374
|
+
},
|
|
10375
|
+
);
|
|
10376
|
+
expect(res.status).toBe(302);
|
|
10377
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10378
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10379
|
+
expect(scope).toBe("vault:work:write");
|
|
10380
|
+
} finally {
|
|
10381
|
+
cleanup();
|
|
10382
|
+
}
|
|
10383
|
+
});
|
|
10384
|
+
|
|
10385
|
+
// Submit: owner DOWNGRADES — selects read on an unnamed vault:write → read.
|
|
10386
|
+
test("owner selects read on an unnamed vault:write → minted vault:work:read (downgrade)", async () => {
|
|
10387
|
+
const { db, cleanup } = await makeDb();
|
|
10388
|
+
try {
|
|
10389
|
+
const owner = await createUser(db, "owner", "pw");
|
|
10390
|
+
const session = createSession(db, { userId: owner.id });
|
|
10391
|
+
const reg = registerClient(db, {
|
|
10392
|
+
redirectUris: ["https://app.example/cb"],
|
|
10393
|
+
status: "approved",
|
|
10394
|
+
});
|
|
10395
|
+
const { verifier, challenge } = makePkce();
|
|
10396
|
+
const res = await submitConsent(
|
|
10397
|
+
db,
|
|
10398
|
+
session.id,
|
|
10399
|
+
reg.client.clientId,
|
|
10400
|
+
"vault:write",
|
|
10401
|
+
challenge,
|
|
10402
|
+
{
|
|
10403
|
+
vault_pick: "work",
|
|
10404
|
+
verb_select: "read",
|
|
10405
|
+
},
|
|
10406
|
+
);
|
|
10407
|
+
expect(res.status).toBe(302);
|
|
10408
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10409
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10410
|
+
expect(scope).toBe("vault:work:read");
|
|
10411
|
+
} finally {
|
|
10412
|
+
cleanup();
|
|
10413
|
+
}
|
|
10414
|
+
});
|
|
10415
|
+
|
|
10416
|
+
// SECURITY: a non-owner who holds only READ on the picked vault forges
|
|
10417
|
+
// verb_select=admin → the server re-derives ownership (no admin held) and
|
|
10418
|
+
// refuses to widen; the cap is the backstop. Minted scope is capped to
|
|
10419
|
+
// their actual authority (read), NOT elevated to admin.
|
|
10420
|
+
test("SECURITY: read-only-assigned non-owner forges verb_select=admin → minted vault:work:read, NOT admin", async () => {
|
|
10421
|
+
const { db, cleanup } = await makeDb();
|
|
10422
|
+
try {
|
|
10423
|
+
await createUser(db, "owner", "pw"); // first admin = owner
|
|
10424
|
+
const reader = await createUser(db, "reader", "pw", { allowMulti: true });
|
|
10425
|
+
// Assign "work" with role=read directly → holds read only (NOT admin).
|
|
10426
|
+
// setUserVaults hardcodes role=write, so insert the read row by hand to
|
|
10427
|
+
// construct the read-only-authority case the cap must defend.
|
|
10428
|
+
db.prepare(
|
|
10429
|
+
"INSERT INTO user_vaults (user_id, vault_name, role, created_at) VALUES (?, ?, 'read', ?)",
|
|
10430
|
+
).run(reader.id, "work", new Date().toISOString());
|
|
10431
|
+
const session = createSession(db, { userId: reader.id });
|
|
10432
|
+
const reg = registerClient(db, {
|
|
10433
|
+
redirectUris: ["https://app.example/cb"],
|
|
10434
|
+
status: "approved",
|
|
10435
|
+
});
|
|
10436
|
+
const { verifier, challenge } = makePkce();
|
|
10437
|
+
const res = await submitConsent(
|
|
10438
|
+
db,
|
|
10439
|
+
session.id,
|
|
10440
|
+
reg.client.clientId,
|
|
10441
|
+
"vault:read",
|
|
10442
|
+
challenge,
|
|
10443
|
+
{
|
|
10444
|
+
vault_pick: "work",
|
|
10445
|
+
verb_select: "admin", // FORGED — reader holds read only
|
|
10446
|
+
},
|
|
10447
|
+
);
|
|
10448
|
+
// Read survives (held); admin never rides along.
|
|
10449
|
+
expect(res.status).toBe(302);
|
|
10450
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10451
|
+
expect(code).toBeTruthy();
|
|
10452
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10453
|
+
expect(scope).toBe("vault:work:read");
|
|
10454
|
+
expect(scope).not.toContain("admin");
|
|
10455
|
+
// And the recorded grant carries no admin verb either.
|
|
10456
|
+
const grant = findGrant(db, reader.id, reg.client.clientId);
|
|
10457
|
+
expect(grant?.scopes ?? []).not.toContain("vault:work:admin");
|
|
10458
|
+
} finally {
|
|
10459
|
+
cleanup();
|
|
10460
|
+
}
|
|
10461
|
+
});
|
|
10462
|
+
|
|
10463
|
+
// SECURITY: a non-admin assigned to "work" picks/forges admin on "other"
|
|
10464
|
+
// (a vault outside their assignment) — the assignment-mismatch gate refuses
|
|
10465
|
+
// before widening ever runs. No token minted.
|
|
10466
|
+
test("SECURITY: forged verb_select=admin against an UNASSIGNED vault → 400 (mismatch gate, no mint)", async () => {
|
|
10467
|
+
const { db, cleanup } = await makeDb();
|
|
10468
|
+
try {
|
|
10469
|
+
await createUser(db, "owner", "pw");
|
|
10470
|
+
const friend = await createUser(db, "friend", "pw", { allowMulti: true });
|
|
10471
|
+
setUserVaults(db, friend.id, ["work"]); // assigned "work" only
|
|
10472
|
+
const session = createSession(db, { userId: friend.id });
|
|
10473
|
+
const reg = registerClient(db, {
|
|
10474
|
+
redirectUris: ["https://app.example/cb"],
|
|
10475
|
+
status: "approved",
|
|
10476
|
+
});
|
|
10477
|
+
const { challenge } = makePkce();
|
|
10478
|
+
const res = await submitConsent(
|
|
10479
|
+
db,
|
|
10480
|
+
session.id,
|
|
10481
|
+
reg.client.clientId,
|
|
10482
|
+
"vault:read",
|
|
10483
|
+
challenge,
|
|
10484
|
+
{
|
|
10485
|
+
vault_pick: "other", // NOT in friend's assignment
|
|
10486
|
+
verb_select: "admin",
|
|
10487
|
+
},
|
|
10488
|
+
);
|
|
10489
|
+
expect(res.status).toBe(400);
|
|
10490
|
+
expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
|
|
10491
|
+
} finally {
|
|
10492
|
+
cleanup();
|
|
10493
|
+
}
|
|
10494
|
+
});
|
|
10495
|
+
|
|
10496
|
+
// Owner without a verb_select field (older form / JS-off) → unchanged
|
|
10497
|
+
// behavior: the unnamed verb narrows as-requested (vault:read → work:read).
|
|
10498
|
+
test("owner with NO verb_select → unchanged narrowing (vault:read → vault:work:read)", async () => {
|
|
10499
|
+
const { db, cleanup } = await makeDb();
|
|
10500
|
+
try {
|
|
10501
|
+
const owner = await createUser(db, "owner", "pw");
|
|
10502
|
+
const session = createSession(db, { userId: owner.id });
|
|
10503
|
+
const reg = registerClient(db, {
|
|
10504
|
+
redirectUris: ["https://app.example/cb"],
|
|
10505
|
+
status: "approved",
|
|
10506
|
+
});
|
|
10507
|
+
const { verifier, challenge } = makePkce();
|
|
10508
|
+
const res = await submitConsent(
|
|
10509
|
+
db,
|
|
10510
|
+
session.id,
|
|
10511
|
+
reg.client.clientId,
|
|
10512
|
+
"vault:read",
|
|
10513
|
+
challenge,
|
|
10514
|
+
{
|
|
10515
|
+
vault_pick: "work",
|
|
10516
|
+
// no verb_select
|
|
10517
|
+
},
|
|
10518
|
+
);
|
|
10519
|
+
expect(res.status).toBe(302);
|
|
10520
|
+
const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
|
|
10521
|
+
const scope = await redeemScope(db, code ?? "", reg.client.clientId, verifier);
|
|
10522
|
+
expect(scope).toBe("vault:work:read");
|
|
10523
|
+
} finally {
|
|
10524
|
+
cleanup();
|
|
10525
|
+
}
|
|
10526
|
+
});
|
|
10527
|
+
});
|