@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
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { handleApiReady } from "../api-ready.ts";
|
|
3
|
+
import type { ModuleState, Supervisor } from "../supervisor.ts";
|
|
4
|
+
|
|
5
|
+
function stubSupervisor(states: ModuleState[]): Supervisor {
|
|
6
|
+
return {
|
|
7
|
+
list: () => states,
|
|
8
|
+
get: (short: string) => states.find((s) => s.short === short),
|
|
9
|
+
start: async () => {
|
|
10
|
+
throw new Error("not implemented");
|
|
11
|
+
},
|
|
12
|
+
stop: async () => undefined,
|
|
13
|
+
restart: async () => undefined,
|
|
14
|
+
} as unknown as Supervisor;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function req(): Request {
|
|
18
|
+
return new Request("http://127.0.0.1/api/ready", {
|
|
19
|
+
headers: { accept: "application/json" },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function moduleState(partial: Partial<ModuleState> & { short: string }): ModuleState {
|
|
24
|
+
return {
|
|
25
|
+
status: "running",
|
|
26
|
+
restartsInWindow: 0,
|
|
27
|
+
...partial,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("handleApiReady — no supervisor (CLI mode)", () => {
|
|
32
|
+
test("returns ready=true + empty arrays when supervisor absent", async () => {
|
|
33
|
+
const res = handleApiReady(req());
|
|
34
|
+
expect(res.status).toBe(200);
|
|
35
|
+
const body = (await res.json()) as {
|
|
36
|
+
ready: boolean;
|
|
37
|
+
ready_modules: string[];
|
|
38
|
+
transient_modules: string[];
|
|
39
|
+
persistent_modules: string[];
|
|
40
|
+
};
|
|
41
|
+
expect(body.ready).toBe(true);
|
|
42
|
+
expect(body.ready_modules).toEqual([]);
|
|
43
|
+
expect(body.transient_modules).toEqual([]);
|
|
44
|
+
expect(body.persistent_modules).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("handleApiReady — supervisor mode", () => {
|
|
49
|
+
test("all modules running past boot window → ready=true", async () => {
|
|
50
|
+
const now = 1_700_000_000_000;
|
|
51
|
+
const startedAt = new Date(now - 60_000).toISOString();
|
|
52
|
+
const sup = stubSupervisor([
|
|
53
|
+
moduleState({ short: "vault", status: "running", startedAt }),
|
|
54
|
+
moduleState({ short: "scribe", status: "running", startedAt }),
|
|
55
|
+
]);
|
|
56
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
57
|
+
const body = (await res.json()) as {
|
|
58
|
+
ready: boolean;
|
|
59
|
+
ready_modules: string[];
|
|
60
|
+
transient_modules: string[];
|
|
61
|
+
persistent_modules: string[];
|
|
62
|
+
};
|
|
63
|
+
expect(body.ready).toBe(true);
|
|
64
|
+
expect(body.ready_modules.sort()).toEqual(["scribe", "vault"]);
|
|
65
|
+
expect(body.transient_modules).toEqual([]);
|
|
66
|
+
expect(body.persistent_modules).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("module inside boot window → transient, ready=false", async () => {
|
|
70
|
+
const now = 1_700_000_000_000;
|
|
71
|
+
const sup = stubSupervisor([
|
|
72
|
+
moduleState({
|
|
73
|
+
short: "vault",
|
|
74
|
+
status: "running",
|
|
75
|
+
startedAt: new Date(now - 10_000).toISOString(),
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
79
|
+
const body = (await res.json()) as {
|
|
80
|
+
ready: boolean;
|
|
81
|
+
ready_modules: string[];
|
|
82
|
+
transient_modules: string[];
|
|
83
|
+
};
|
|
84
|
+
expect(body.ready).toBe(false);
|
|
85
|
+
expect(body.transient_modules).toEqual(["vault"]);
|
|
86
|
+
expect(body.ready_modules).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("starting + restarting + crashed all classified correctly", async () => {
|
|
90
|
+
const now = 1_700_000_000_000;
|
|
91
|
+
const sup = stubSupervisor([
|
|
92
|
+
moduleState({ short: "vault", status: "starting" }),
|
|
93
|
+
moduleState({ short: "scribe", status: "restarting" }),
|
|
94
|
+
moduleState({ short: "notes", status: "crashed" }),
|
|
95
|
+
moduleState({ short: "channel", status: "stopped" }),
|
|
96
|
+
moduleState({
|
|
97
|
+
short: "runner",
|
|
98
|
+
status: "running",
|
|
99
|
+
startedAt: new Date(now - 60_000).toISOString(),
|
|
100
|
+
}),
|
|
101
|
+
]);
|
|
102
|
+
const res = handleApiReady(req(), { supervisor: sup, now: () => now });
|
|
103
|
+
const body = (await res.json()) as {
|
|
104
|
+
ready: boolean;
|
|
105
|
+
ready_modules: string[];
|
|
106
|
+
transient_modules: string[];
|
|
107
|
+
persistent_modules: string[];
|
|
108
|
+
};
|
|
109
|
+
expect(body.ready).toBe(false);
|
|
110
|
+
expect(body.ready_modules).toEqual(["runner"]);
|
|
111
|
+
expect(body.transient_modules.sort()).toEqual(["scribe", "vault"]);
|
|
112
|
+
expect(body.persistent_modules.sort()).toEqual(["channel", "notes"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("only crashed/stopped + nothing transient → ready=false (still failing)", async () => {
|
|
116
|
+
const sup = stubSupervisor([moduleState({ short: "vault", status: "crashed" })]);
|
|
117
|
+
const res = handleApiReady(req(), { supervisor: sup });
|
|
118
|
+
const body = (await res.json()) as { ready: boolean };
|
|
119
|
+
expect(body.ready).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("handleApiReady — method check", () => {
|
|
124
|
+
test("rejects non-GET", () => {
|
|
125
|
+
const r = new Request("http://127.0.0.1/api/ready", { method: "POST" });
|
|
126
|
+
const res = handleApiReady(r);
|
|
127
|
+
expect(res.status).toBe(405);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("accepts HEAD", () => {
|
|
131
|
+
const r = new Request("http://127.0.0.1/api/ready", { method: "HEAD" });
|
|
132
|
+
const res = handleApiReady(r);
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -318,3 +318,387 @@ describe("POST /api/auth/revoke-token (closes hub#220)", () => {
|
|
|
318
318
|
}
|
|
319
319
|
});
|
|
320
320
|
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Capability attenuation — symmetric to mint-token (hub#452). A bearer that
|
|
324
|
+
// is NOT host:auth may revoke a jti iff every one of the jti's recorded scopes
|
|
325
|
+
// is one the bearer could have minted (`canGrant`): you may revoke what you
|
|
326
|
+
// could mint. This is the security-critical half of the auth arc.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
describe("POST /api/auth/revoke-token — capability attenuation (symmetric to hub#452)", () => {
|
|
329
|
+
/**
|
|
330
|
+
* Hand-mint a `vault:<vault>:admin` bearer the way the SPA / mcp-install
|
|
331
|
+
* path obtains one (scope `vault:<vault>:admin`, aud `vault.<vault>`,
|
|
332
|
+
* vaultScope `[vault]`). Mirrors `mintVaultAdminBearer` in
|
|
333
|
+
* api-mint-token.test.ts.
|
|
334
|
+
*/
|
|
335
|
+
async function mintVaultAdminBearer(
|
|
336
|
+
db: ReturnType<typeof openHubDb>,
|
|
337
|
+
userId: string,
|
|
338
|
+
vault: string,
|
|
339
|
+
): Promise<string> {
|
|
340
|
+
const signed = await signAccessToken(db, {
|
|
341
|
+
sub: userId,
|
|
342
|
+
scopes: [`vault:${vault}:admin`],
|
|
343
|
+
audience: `vault.${vault}`,
|
|
344
|
+
clientId: "parachute-hub",
|
|
345
|
+
issuer: ISSUER,
|
|
346
|
+
ttlSeconds: 3600,
|
|
347
|
+
vaultScope: [vault],
|
|
348
|
+
});
|
|
349
|
+
return signed.token;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
test("host:auth bearer revokes ANY jti (preserved) — incl. a host-scoped target", async () => {
|
|
353
|
+
const h = makeHarness();
|
|
354
|
+
try {
|
|
355
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
356
|
+
try {
|
|
357
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
358
|
+
// Seed a target whose own scopes a vault-admin could never mint.
|
|
359
|
+
const jti = await seedToken(db, userId, ["parachute:host:auth"]);
|
|
360
|
+
const resp = await handleApiRevokeToken(
|
|
361
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
362
|
+
{ db, issuer: ISSUER },
|
|
363
|
+
);
|
|
364
|
+
expect(resp.status).toBe(200);
|
|
365
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
366
|
+
} finally {
|
|
367
|
+
db.close();
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
h.cleanup();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("vault:work:admin revokes a vault:work:write jti → 200 (could have minted it)", async () => {
|
|
375
|
+
const h = makeHarness();
|
|
376
|
+
try {
|
|
377
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
378
|
+
try {
|
|
379
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
380
|
+
const jti = await seedToken(db, userId, ["vault:work:write"]);
|
|
381
|
+
const resp = await handleApiRevokeToken(
|
|
382
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
383
|
+
{ db, issuer: ISSUER },
|
|
384
|
+
);
|
|
385
|
+
expect(resp.status).toBe(200);
|
|
386
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
387
|
+
} finally {
|
|
388
|
+
db.close();
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
h.cleanup();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("vault:work:admin revokes a vault:work:admin jti → 200", async () => {
|
|
396
|
+
const h = makeHarness();
|
|
397
|
+
try {
|
|
398
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
399
|
+
try {
|
|
400
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
401
|
+
const jti = await seedToken(db, userId, ["vault:work:admin"]);
|
|
402
|
+
const resp = await handleApiRevokeToken(
|
|
403
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
404
|
+
{ db, issuer: ISSUER },
|
|
405
|
+
);
|
|
406
|
+
expect(resp.status).toBe(200);
|
|
407
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
408
|
+
} finally {
|
|
409
|
+
db.close();
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
h.cleanup();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("CROSS-VAULT BLOCKED: vault:work:admin revokes vault:other:write jti → 403, NOT revoked", async () => {
|
|
417
|
+
const h = makeHarness();
|
|
418
|
+
try {
|
|
419
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
420
|
+
try {
|
|
421
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
422
|
+
const jti = await seedToken(db, userId, ["vault:other:write"]);
|
|
423
|
+
const resp = await handleApiRevokeToken(
|
|
424
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
425
|
+
{ db, issuer: ISSUER },
|
|
426
|
+
);
|
|
427
|
+
expect(resp.status).toBe(403);
|
|
428
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
429
|
+
// SECURITY: the cross-vault token must NOT have been revoked.
|
|
430
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
431
|
+
} finally {
|
|
432
|
+
db.close();
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
h.cleanup();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("HOST-ESCALATION BLOCKED: vault:work:admin revokes parachute:host:auth jti → 403, NOT revoked", async () => {
|
|
440
|
+
const h = makeHarness();
|
|
441
|
+
try {
|
|
442
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
443
|
+
try {
|
|
444
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
445
|
+
const jti = await seedToken(db, userId, ["parachute:host:auth"]);
|
|
446
|
+
const resp = await handleApiRevokeToken(
|
|
447
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
448
|
+
{ db, issuer: ISSUER },
|
|
449
|
+
);
|
|
450
|
+
expect(resp.status).toBe(403);
|
|
451
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
452
|
+
} finally {
|
|
453
|
+
db.close();
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
h.cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("NO LEAK: vault:work:admin revokes an UNKNOWN jti → 404 (same as host:auth, no leak)", async () => {
|
|
461
|
+
const h = makeHarness();
|
|
462
|
+
try {
|
|
463
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
464
|
+
try {
|
|
465
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
466
|
+
const resp = await handleApiRevokeToken(
|
|
467
|
+
jsonRequest({ jti: "no-such-jti-ever-minted" }, { authorization: `Bearer ${bearer}` }),
|
|
468
|
+
{ db, issuer: ISSUER },
|
|
469
|
+
);
|
|
470
|
+
// Identical to today's unknown-jti behavior for a host:auth bearer:
|
|
471
|
+
// the attenuated caller cannot distinguish "doesn't exist" here.
|
|
472
|
+
expect(resp.status).toBe(404);
|
|
473
|
+
expect(((await resp.json()) as { error: string }).error).toBe("not_found");
|
|
474
|
+
} finally {
|
|
475
|
+
db.close();
|
|
476
|
+
}
|
|
477
|
+
} finally {
|
|
478
|
+
h.cleanup();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("ENTRY GATE: vault:work:read bearer (no admin, no host) → 403, NOT revoked", async () => {
|
|
483
|
+
const h = makeHarness();
|
|
484
|
+
try {
|
|
485
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
486
|
+
try {
|
|
487
|
+
const readOnly = await signAccessToken(db, {
|
|
488
|
+
sub: userId,
|
|
489
|
+
scopes: ["vault:work:read"],
|
|
490
|
+
audience: "vault.work",
|
|
491
|
+
clientId: "parachute-hub",
|
|
492
|
+
issuer: ISSUER,
|
|
493
|
+
ttlSeconds: 3600,
|
|
494
|
+
vaultScope: ["work"],
|
|
495
|
+
});
|
|
496
|
+
// Seed a target the read bearer could never mint anyway.
|
|
497
|
+
const jti = await seedToken(db, userId, ["vault:work:write"]);
|
|
498
|
+
const resp = await handleApiRevokeToken(
|
|
499
|
+
jsonRequest({ jti }, { authorization: `Bearer ${readOnly.token}` }),
|
|
500
|
+
{ db, issuer: ISSUER },
|
|
501
|
+
);
|
|
502
|
+
expect(resp.status).toBe(403);
|
|
503
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
504
|
+
// Entry-gated before any lookup — the target stays intact.
|
|
505
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
506
|
+
} finally {
|
|
507
|
+
db.close();
|
|
508
|
+
}
|
|
509
|
+
} finally {
|
|
510
|
+
h.cleanup();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("MULTI-SCOPE: vault:work:admin revokes vault:work:read+write jti → 200 (all in authority)", async () => {
|
|
515
|
+
const h = makeHarness();
|
|
516
|
+
try {
|
|
517
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
518
|
+
try {
|
|
519
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
520
|
+
const jti = await seedToken(db, userId, ["vault:work:read", "vault:work:write"]);
|
|
521
|
+
const resp = await handleApiRevokeToken(
|
|
522
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
523
|
+
{ db, issuer: ISSUER },
|
|
524
|
+
);
|
|
525
|
+
expect(resp.status).toBe(200);
|
|
526
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
527
|
+
} finally {
|
|
528
|
+
db.close();
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
h.cleanup();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("MULTI-SCOPE BLOCKED: one out-of-authority scope blocks the whole revoke → 403, NOT revoked", async () => {
|
|
536
|
+
const h = makeHarness();
|
|
537
|
+
try {
|
|
538
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
539
|
+
try {
|
|
540
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
541
|
+
// In-authority scope + one cross-vault scope: must block entirely.
|
|
542
|
+
const jti = await seedToken(db, userId, ["vault:work:write", "vault:other:read"]);
|
|
543
|
+
const resp = await handleApiRevokeToken(
|
|
544
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
545
|
+
{ db, issuer: ISSUER },
|
|
546
|
+
);
|
|
547
|
+
expect(resp.status).toBe(403);
|
|
548
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
549
|
+
} finally {
|
|
550
|
+
db.close();
|
|
551
|
+
}
|
|
552
|
+
} finally {
|
|
553
|
+
h.cleanup();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("host:admin bearer revokes a vault:<name>:admin jti → 200 (rule 2 symmetry)", async () => {
|
|
558
|
+
const h = makeHarness();
|
|
559
|
+
try {
|
|
560
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
561
|
+
try {
|
|
562
|
+
const hostAdmin = await signAccessToken(db, {
|
|
563
|
+
sub: userId,
|
|
564
|
+
scopes: ["parachute:host:admin"],
|
|
565
|
+
audience: "hub",
|
|
566
|
+
clientId: "parachute-hub",
|
|
567
|
+
issuer: ISSUER,
|
|
568
|
+
ttlSeconds: 3600,
|
|
569
|
+
});
|
|
570
|
+
const jti = await seedToken(db, userId, ["vault:work:admin"]);
|
|
571
|
+
const resp = await handleApiRevokeToken(
|
|
572
|
+
jsonRequest({ jti }, { authorization: `Bearer ${hostAdmin.token}` }),
|
|
573
|
+
{ db, issuer: ISSUER },
|
|
574
|
+
);
|
|
575
|
+
expect(resp.status).toBe(200);
|
|
576
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
577
|
+
} finally {
|
|
578
|
+
db.close();
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
h.cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("HOST-ESCALATION BLOCKED: host:admin bearer revokes parachute:host:auth jti → 403, NOT revoked", async () => {
|
|
586
|
+
const h = makeHarness();
|
|
587
|
+
try {
|
|
588
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
589
|
+
try {
|
|
590
|
+
// host:admin is NOT host:auth, so it goes through the per-jti
|
|
591
|
+
// attenuation check. `parachute:host:auth` is non-requestable and not
|
|
592
|
+
// a vault-admin scope, so canGrant returns false for it → 403.
|
|
593
|
+
const hostAdmin = await signAccessToken(db, {
|
|
594
|
+
sub: userId,
|
|
595
|
+
scopes: ["parachute:host:admin"],
|
|
596
|
+
audience: "hub",
|
|
597
|
+
clientId: "parachute-hub",
|
|
598
|
+
issuer: ISSUER,
|
|
599
|
+
ttlSeconds: 3600,
|
|
600
|
+
});
|
|
601
|
+
const jti = await seedToken(db, userId, ["parachute:host:auth"]);
|
|
602
|
+
const resp = await handleApiRevokeToken(
|
|
603
|
+
jsonRequest({ jti }, { authorization: `Bearer ${hostAdmin.token}` }),
|
|
604
|
+
{ db, issuer: ISSUER },
|
|
605
|
+
);
|
|
606
|
+
expect(resp.status).toBe(403);
|
|
607
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
608
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
609
|
+
} finally {
|
|
610
|
+
db.close();
|
|
611
|
+
}
|
|
612
|
+
} finally {
|
|
613
|
+
h.cleanup();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("EMPTY-SCOPES GUARD: non-host:auth bearer cannot revoke a scopeless target → 403, NOT revoked", async () => {
|
|
618
|
+
const h = makeHarness();
|
|
619
|
+
try {
|
|
620
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
621
|
+
try {
|
|
622
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
623
|
+
// Seed a registry row with ZERO recorded scopes directly — the CLI/SPA
|
|
624
|
+
// never mint these, but a vacuous `[].filter(canGrant)` would
|
|
625
|
+
// otherwise pass the authority check for any entry-gate-clearing
|
|
626
|
+
// bearer. The explicit empty-scopes guard must 403 instead.
|
|
627
|
+
const jti = "scopeless-target-jti";
|
|
628
|
+
recordTokenMint(db, {
|
|
629
|
+
jti,
|
|
630
|
+
createdVia: "cli_mint",
|
|
631
|
+
subject: userId,
|
|
632
|
+
clientId: "parachute-hub",
|
|
633
|
+
scopes: [],
|
|
634
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
635
|
+
});
|
|
636
|
+
expect(findTokenRowByJti(db, jti)?.scopes).toEqual([]);
|
|
637
|
+
|
|
638
|
+
const resp = await handleApiRevokeToken(
|
|
639
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
640
|
+
{ db, issuer: ISSUER },
|
|
641
|
+
);
|
|
642
|
+
expect(resp.status).toBe(403);
|
|
643
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
644
|
+
// SECURITY: the scopeless token must NOT have been revoked.
|
|
645
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
646
|
+
} finally {
|
|
647
|
+
db.close();
|
|
648
|
+
}
|
|
649
|
+
} finally {
|
|
650
|
+
h.cleanup();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("EMPTY-SCOPES: host:auth bearer CAN revoke a scopeless target → 200 (guard is non-host:auth-only)", async () => {
|
|
655
|
+
const h = makeHarness();
|
|
656
|
+
try {
|
|
657
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
658
|
+
try {
|
|
659
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
660
|
+
const jti = "scopeless-target-host-auth";
|
|
661
|
+
recordTokenMint(db, {
|
|
662
|
+
jti,
|
|
663
|
+
createdVia: "cli_mint",
|
|
664
|
+
subject: userId,
|
|
665
|
+
clientId: "parachute-hub",
|
|
666
|
+
scopes: [],
|
|
667
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
668
|
+
});
|
|
669
|
+
const resp = await handleApiRevokeToken(
|
|
670
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
671
|
+
{ db, issuer: ISSUER },
|
|
672
|
+
);
|
|
673
|
+
expect(resp.status).toBe(200);
|
|
674
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
675
|
+
} finally {
|
|
676
|
+
db.close();
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
h.cleanup();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("JTI LENGTH GUARD: jti longer than the cap → 400 invalid_request", async () => {
|
|
684
|
+
const h = makeHarness();
|
|
685
|
+
try {
|
|
686
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
687
|
+
try {
|
|
688
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
689
|
+
const resp = await handleApiRevokeToken(
|
|
690
|
+
jsonRequest({ jti: "x".repeat(257) }, { authorization: `Bearer ${op.token}` }),
|
|
691
|
+
{ db, issuer: ISSUER },
|
|
692
|
+
);
|
|
693
|
+
expect(resp.status).toBe(400);
|
|
694
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
695
|
+
expect(body.error).toBe("invalid_request");
|
|
696
|
+
expect(body.error_description).toContain("256");
|
|
697
|
+
} finally {
|
|
698
|
+
db.close();
|
|
699
|
+
}
|
|
700
|
+
} finally {
|
|
701
|
+
h.cleanup();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
});
|