@openparachute/hub 0.7.4-rc.11 → 0.7.4-rc.13
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 +1 -1
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/auth.test.ts +69 -0
- package/src/__tests__/clients.test.ts +89 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/admin-clients.ts +55 -3
- package/src/admin-vaults.ts +45 -13
- package/src/clients.ts +35 -0
- package/src/commands/auth.ts +52 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/hub-server.ts +28 -1
package/package.json
CHANGED
|
@@ -12,8 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { handleApproveClient, handleGetClient } from "../admin-clients.ts";
|
|
15
|
+
import { handleApproveClient, handleDeleteClient, handleGetClient } from "../admin-clients.ts";
|
|
16
16
|
import { approveClient, getClient, registerClient } from "../clients.ts";
|
|
17
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
17
18
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
18
19
|
import { signAccessToken } from "../jwt-sign.ts";
|
|
19
20
|
import { createUser } from "../users.ts";
|
|
@@ -83,6 +84,23 @@ function approveReq(clientId: string, bearer?: string, method = "POST"): Request
|
|
|
83
84
|
return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, init);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
// DELETE hits the TOP-LEVEL /oauth/clients/<id> prefix (the path the surface
|
|
88
|
+
// remove-flow calls), not the /api/... admin prefix the GET/approve use.
|
|
89
|
+
function deleteReq(clientId: string, bearer?: string, method = "DELETE"): Request {
|
|
90
|
+
const init: RequestInit = { method };
|
|
91
|
+
if (bearer) init.headers = { authorization: `Bearer ${bearer}` };
|
|
92
|
+
return new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(clientId)}`, init);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function regApproved(name?: string): string {
|
|
96
|
+
const r = registerClient(harness.db, {
|
|
97
|
+
redirectUris: ["https://app.example/cb"],
|
|
98
|
+
scopes: ["vault:work:read"],
|
|
99
|
+
...(name !== undefined ? { clientName: name } : {}),
|
|
100
|
+
});
|
|
101
|
+
return r.client.clientId;
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
describe("handleGetClient", () => {
|
|
87
105
|
test("401 without Bearer", async () => {
|
|
88
106
|
const id = regPending("App");
|
|
@@ -460,3 +478,87 @@ describe("handleApproveClient", () => {
|
|
|
460
478
|
});
|
|
461
479
|
});
|
|
462
480
|
});
|
|
481
|
+
|
|
482
|
+
// hub#640 — RFC 7592 client deregistration. The surface fires DELETE on the
|
|
483
|
+
// top-level /oauth/clients/<id> path; the handler is operator-bearer-gated,
|
|
484
|
+
// returns 204 on delete (cascading grants + auth_codes), 404 when absent.
|
|
485
|
+
describe("handleDeleteClient", () => {
|
|
486
|
+
test("401 without Bearer (row survives)", async () => {
|
|
487
|
+
const id = regApproved("App");
|
|
488
|
+
const res = await handleDeleteClient(deleteReq(id), id, {
|
|
489
|
+
db: harness.db,
|
|
490
|
+
issuer: ISSUER,
|
|
491
|
+
});
|
|
492
|
+
expect(res.status).toBe(401);
|
|
493
|
+
expect(getClient(harness.db, id)).not.toBeNull();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("403 with the wrong scope (row survives)", async () => {
|
|
497
|
+
const { bearer } = await makeOperatorBearer(["parachute:host:auth"]);
|
|
498
|
+
const id = regApproved("App");
|
|
499
|
+
const res = await handleDeleteClient(deleteReq(id, bearer), id, {
|
|
500
|
+
db: harness.db,
|
|
501
|
+
issuer: ISSUER,
|
|
502
|
+
});
|
|
503
|
+
expect(res.status).toBe(403);
|
|
504
|
+
expect(getClient(harness.db, id)).not.toBeNull();
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("204 on an existing client + cascade gone + audit line", async () => {
|
|
508
|
+
const { bearer, userId } = await makeOperatorBearer();
|
|
509
|
+
const id = regApproved("Notes");
|
|
510
|
+
// Plant a grant so the cascade has something to remove.
|
|
511
|
+
recordGrant(harness.db, userId, id, ["vault:work:read"]);
|
|
512
|
+
expect(findGrant(harness.db, userId, id)).not.toBeNull();
|
|
513
|
+
|
|
514
|
+
const logs: string[] = [];
|
|
515
|
+
const originalLog = console.log;
|
|
516
|
+
console.log = (...args: unknown[]) => {
|
|
517
|
+
logs.push(args.map(String).join(" "));
|
|
518
|
+
};
|
|
519
|
+
let res: Response;
|
|
520
|
+
try {
|
|
521
|
+
res = await handleDeleteClient(deleteReq(id, bearer), id, {
|
|
522
|
+
db: harness.db,
|
|
523
|
+
issuer: ISSUER,
|
|
524
|
+
});
|
|
525
|
+
} finally {
|
|
526
|
+
console.log = originalLog;
|
|
527
|
+
}
|
|
528
|
+
expect(res.status).toBe(204);
|
|
529
|
+
// 204 carries no body.
|
|
530
|
+
expect(await res.text()).toBe("");
|
|
531
|
+
// Client + its grant are gone.
|
|
532
|
+
expect(getClient(harness.db, id)).toBeNull();
|
|
533
|
+
expect(findGrant(harness.db, userId, id)).toBeNull();
|
|
534
|
+
|
|
535
|
+
const line = logs.find((l) => l.startsWith("client deleted:"));
|
|
536
|
+
expect(line).toBeDefined();
|
|
537
|
+
expect(line).toContain(`client_id=${id}`);
|
|
538
|
+
expect(line).toContain("client_name=Notes");
|
|
539
|
+
expect(line).toContain(`remover_sub=${userId}`);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("404 on an absent client_id", async () => {
|
|
543
|
+
const { bearer } = await makeOperatorBearer();
|
|
544
|
+
const res = await handleDeleteClient(deleteReq("nope", bearer), "nope", {
|
|
545
|
+
db: harness.db,
|
|
546
|
+
issuer: ISSUER,
|
|
547
|
+
});
|
|
548
|
+
expect(res.status).toBe(404);
|
|
549
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
550
|
+
expect(body.error).toBe("not_found");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("405 on a non-DELETE method", async () => {
|
|
554
|
+
const { bearer } = await makeOperatorBearer();
|
|
555
|
+
const id = regApproved();
|
|
556
|
+
const res = await handleDeleteClient(deleteReq(id, bearer, "GET"), id, {
|
|
557
|
+
db: harness.db,
|
|
558
|
+
issuer: ISSUER,
|
|
559
|
+
});
|
|
560
|
+
expect(res.status).toBe(405);
|
|
561
|
+
// Row untouched.
|
|
562
|
+
expect(getClient(harness.db, id)).not.toBeNull();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
@@ -940,27 +940,70 @@ describe("DELETE /vaults/<name> — gates", () => {
|
|
|
940
940
|
}
|
|
941
941
|
});
|
|
942
942
|
|
|
943
|
-
test("
|
|
943
|
+
test("#678: deleting the LAST vault cascades + deletes (no 409), with a last_vault warning", async () => {
|
|
944
944
|
const h = makeHarness();
|
|
945
945
|
try {
|
|
946
946
|
const db = openHubDb(hubDbPath(h.dir));
|
|
947
947
|
try {
|
|
948
948
|
rotateSigningKey(db);
|
|
949
|
-
|
|
949
|
+
// Only one vault on the hub — the previously-refused last-vault case.
|
|
950
|
+
writeVaults(h.manifestPath, ["solo"]);
|
|
951
|
+
|
|
952
|
+
// Seed identity artifacts naming the soon-to-be-deleted last vault.
|
|
953
|
+
registryRow(db, "jti-solo", ["vault:solo:write"]);
|
|
954
|
+
const carol = await createUser(db, "carol", "carol-passphrase-123");
|
|
955
|
+
const client = registerClient(db, { redirectUris: ["https://d.example/cb"] }).client
|
|
956
|
+
.clientId;
|
|
957
|
+
recordGrant(db, carol.id, client, ["vault:solo:admin"]);
|
|
958
|
+
setUserVaults(db, carol.id, ["solo"]);
|
|
959
|
+
|
|
950
960
|
const runner = stubRun();
|
|
951
961
|
const res = await callDelete({
|
|
952
|
-
name: "
|
|
962
|
+
name: "solo",
|
|
953
963
|
db,
|
|
954
964
|
manifestPath: h.manifestPath,
|
|
955
965
|
connectionsStorePath: join(h.dir, "connections.json"),
|
|
956
966
|
runCommand: runner.run,
|
|
957
967
|
});
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
expect(
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
968
|
+
|
|
969
|
+
// No 409 — the delete completes.
|
|
970
|
+
expect(res.status).toBe(200);
|
|
971
|
+
const out = (await res.json()) as {
|
|
972
|
+
ok: boolean;
|
|
973
|
+
cascade: {
|
|
974
|
+
tokens_revoked: number;
|
|
975
|
+
grants_dropped: number;
|
|
976
|
+
user_vaults_removed: number;
|
|
977
|
+
vault_removed: boolean;
|
|
978
|
+
};
|
|
979
|
+
warnings?: { step: string; detail: string }[];
|
|
980
|
+
};
|
|
981
|
+
expect(out.ok).toBe(true);
|
|
982
|
+
|
|
983
|
+
// The cascade ran for the last vault: tokens/grants/assignments gone.
|
|
984
|
+
expect(out.cascade.tokens_revoked).toBe(1);
|
|
985
|
+
expect(findTokenRowByJti(db, "jti-solo")?.revokedAt).not.toBeNull();
|
|
986
|
+
expect(out.cascade.grants_dropped).toBe(1);
|
|
987
|
+
expect(findGrant(db, carol.id, client)).toBeNull();
|
|
988
|
+
expect(out.cascade.user_vaults_removed).toBe(1);
|
|
989
|
+
expect(
|
|
990
|
+
db
|
|
991
|
+
.query<{ vault_name: string }, [string]>(
|
|
992
|
+
"SELECT vault_name FROM user_vaults WHERE user_id = ?",
|
|
993
|
+
)
|
|
994
|
+
.all(carol.id),
|
|
995
|
+
).toEqual([]);
|
|
996
|
+
|
|
997
|
+
// The underlying vault remove ran (the cascade no longer skips it).
|
|
998
|
+
expect(runner.calls).toContainEqual(["parachute-vault", "remove", "solo", "--yes"]);
|
|
999
|
+
expect(out.cascade.vault_removed).toBe(true);
|
|
1000
|
+
|
|
1001
|
+
// A last_vault heads-up warning is surfaced (name-agnostic — does not
|
|
1002
|
+
// assume "default"); the auto_create:false marker prevents resurrection.
|
|
1003
|
+
const lastVaultWarning = out.warnings?.find((w) => w.step === "last_vault");
|
|
1004
|
+
expect(lastVaultWarning).toBeDefined();
|
|
1005
|
+
expect(lastVaultWarning?.detail).toContain("auto_create: false");
|
|
1006
|
+
expect(lastVaultWarning?.detail).not.toContain('"default"');
|
|
964
1007
|
} finally {
|
|
965
1008
|
db.close();
|
|
966
1009
|
}
|
|
@@ -849,6 +849,75 @@ describe("parachute auth pending-clients / approve-client", () => {
|
|
|
849
849
|
});
|
|
850
850
|
});
|
|
851
851
|
|
|
852
|
+
// hub#640 — RFC 7592 deregistration from the terminal.
|
|
853
|
+
describe("parachute auth revoke-client", () => {
|
|
854
|
+
test("revoke-client without an arg is a usage error", async () => {
|
|
855
|
+
const tmp = makeTmp();
|
|
856
|
+
try {
|
|
857
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
858
|
+
const { code, stderr } = await captureOutput(() => auth(["revoke-client"], deps));
|
|
859
|
+
expect(code).toBe(1);
|
|
860
|
+
expect(stderr).toContain("missing client_id");
|
|
861
|
+
} finally {
|
|
862
|
+
tmp.cleanup();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("revoke-client <unknown> exits 1 with a friendly message", async () => {
|
|
867
|
+
const tmp = makeTmp();
|
|
868
|
+
try {
|
|
869
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
870
|
+
const { code, stderr } = await captureOutput(() => auth(["revoke-client", "no-such"], deps));
|
|
871
|
+
expect(code).toBe(1);
|
|
872
|
+
expect(stderr).toContain("no OAuth client");
|
|
873
|
+
} finally {
|
|
874
|
+
tmp.cleanup();
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("revoke-client deletes the client + cascades its grant + emits audit line", async () => {
|
|
879
|
+
const tmp = makeTmp();
|
|
880
|
+
try {
|
|
881
|
+
const db = openHubDb(tmp.dbPath);
|
|
882
|
+
let userId: string;
|
|
883
|
+
let clientId: string;
|
|
884
|
+
try {
|
|
885
|
+
const user = await createUser(db, "owner", "pw");
|
|
886
|
+
userId = user.id;
|
|
887
|
+
clientId = registerClient(db, {
|
|
888
|
+
redirectUris: ["https://app.example/cb"],
|
|
889
|
+
clientName: "MyApp",
|
|
890
|
+
}).client.clientId;
|
|
891
|
+
recordGrant(db, userId, clientId, ["vault:work:read"]);
|
|
892
|
+
expect(findGrant(db, userId, clientId)).not.toBeNull();
|
|
893
|
+
} finally {
|
|
894
|
+
db.close();
|
|
895
|
+
}
|
|
896
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
897
|
+
const { code, stdout } = await captureOutput(() => auth(["revoke-client", clientId], deps));
|
|
898
|
+
expect(code).toBe(0);
|
|
899
|
+
expect(stdout).toContain("Deregistered OAuth client");
|
|
900
|
+
// Audit line for greppability (matches the route's shape, remover_sub=cli).
|
|
901
|
+
expect(stdout).toContain(`client deleted: client_id=${clientId}`);
|
|
902
|
+
expect(stdout).toContain("client_name=MyApp");
|
|
903
|
+
expect(stdout).toContain("remover_sub=cli");
|
|
904
|
+
|
|
905
|
+
// Verify the cascade actually landed in the db.
|
|
906
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
907
|
+
try {
|
|
908
|
+
expect(
|
|
909
|
+
db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(clientId),
|
|
910
|
+
).toBeNull();
|
|
911
|
+
expect(findGrant(db2, userId, clientId)).toBeNull();
|
|
912
|
+
} finally {
|
|
913
|
+
db2.close();
|
|
914
|
+
}
|
|
915
|
+
} finally {
|
|
916
|
+
tmp.cleanup();
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
852
921
|
// closes #75 — operator-facing controls for the OAuth consent skip-list.
|
|
853
922
|
describe("parachute auth list-grants / revoke-grant", () => {
|
|
854
923
|
test("list-grants shows the seeding hint when no users exist", async () => {
|
|
@@ -2,9 +2,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { issueAuthCode } from "../auth-codes.ts";
|
|
5
6
|
import {
|
|
6
7
|
InvalidRedirectUriError,
|
|
7
8
|
approveClient,
|
|
9
|
+
deleteClient,
|
|
8
10
|
expandRedirectUrisForHubOrigins,
|
|
9
11
|
getClient,
|
|
10
12
|
isValidRedirectUri,
|
|
@@ -13,7 +15,9 @@ import {
|
|
|
13
15
|
requireRegisteredRedirectUri,
|
|
14
16
|
verifyClientSecret,
|
|
15
17
|
} from "../clients.ts";
|
|
18
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
16
19
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
|
+
import { createUser } from "../users.ts";
|
|
17
21
|
|
|
18
22
|
function makeDb() {
|
|
19
23
|
const configDir = mkdtempSync(join(tmpdir(), "phub-clients-"));
|
|
@@ -341,6 +345,91 @@ describe("approval gate (#74)", () => {
|
|
|
341
345
|
});
|
|
342
346
|
});
|
|
343
347
|
|
|
348
|
+
describe("deleteClient cascade (hub#640 RFC 7592 deregistration)", () => {
|
|
349
|
+
test("returns false for an unknown client_id (nothing to delete)", () => {
|
|
350
|
+
const { db, cleanup } = makeDb();
|
|
351
|
+
try {
|
|
352
|
+
expect(deleteClient(db, "no-such-client")).toBe(false);
|
|
353
|
+
} finally {
|
|
354
|
+
cleanup();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("deletes the client row and reports true", () => {
|
|
359
|
+
const { db, cleanup } = makeDb();
|
|
360
|
+
try {
|
|
361
|
+
const r = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
362
|
+
expect(getClient(db, r.client.clientId)).not.toBeNull();
|
|
363
|
+
expect(deleteClient(db, r.client.clientId)).toBe(true);
|
|
364
|
+
expect(getClient(db, r.client.clientId)).toBeNull();
|
|
365
|
+
} finally {
|
|
366
|
+
cleanup();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("cascades dependent grants + auth_codes (FK ON, no ON DELETE CASCADE)", async () => {
|
|
371
|
+
const { db, cleanup } = makeDb();
|
|
372
|
+
try {
|
|
373
|
+
const user = await createUser(db, "owner", "pw");
|
|
374
|
+
const r = registerClient(db, {
|
|
375
|
+
redirectUris: ["https://app.example/cb"],
|
|
376
|
+
scopes: ["vault:work:read"],
|
|
377
|
+
});
|
|
378
|
+
const clientId = r.client.clientId;
|
|
379
|
+
|
|
380
|
+
// Plant a live grant + auth_code that reference the client. Without the
|
|
381
|
+
// cascade, the bare DELETE FROM clients would throw a FK violation while
|
|
382
|
+
// these rows exist (PRAGMA foreign_keys = ON).
|
|
383
|
+
recordGrant(db, user.id, clientId, ["vault:work:read"]);
|
|
384
|
+
const ac = issueAuthCode(db, {
|
|
385
|
+
clientId,
|
|
386
|
+
userId: user.id,
|
|
387
|
+
redirectUri: "https://app.example/cb",
|
|
388
|
+
scopes: ["vault:work:read"],
|
|
389
|
+
codeChallenge: "x".repeat(43),
|
|
390
|
+
codeChallengeMethod: "S256",
|
|
391
|
+
});
|
|
392
|
+
// Sanity: the dependents are present before the delete.
|
|
393
|
+
expect(findGrant(db, user.id, clientId)).not.toBeNull();
|
|
394
|
+
expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).not.toBeNull();
|
|
395
|
+
|
|
396
|
+
// Delete cascades — no FK throw, true returned.
|
|
397
|
+
expect(deleteClient(db, clientId)).toBe(true);
|
|
398
|
+
|
|
399
|
+
// Client + both dependents are gone.
|
|
400
|
+
expect(getClient(db, clientId)).toBeNull();
|
|
401
|
+
expect(findGrant(db, user.id, clientId)).toBeNull();
|
|
402
|
+
expect(db.query("SELECT code FROM auth_codes WHERE code = ?").get(ac.code)).toBeNull();
|
|
403
|
+
// The user row is untouched — only client-keyed rows cascade.
|
|
404
|
+
expect(db.query("SELECT id FROM users WHERE id = ?").get(user.id)).not.toBeNull();
|
|
405
|
+
} finally {
|
|
406
|
+
cleanup();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("transactional: deleting one client leaves a sibling client's grants intact", async () => {
|
|
411
|
+
const { db, cleanup } = makeDb();
|
|
412
|
+
try {
|
|
413
|
+
const user = await createUser(db, "owner", "pw");
|
|
414
|
+
const keep = registerClient(db, { redirectUris: ["https://keep.example/cb"] });
|
|
415
|
+
const drop = registerClient(db, { redirectUris: ["https://drop.example/cb"] });
|
|
416
|
+
recordGrant(db, user.id, keep.client.clientId, ["vault:work:read"]);
|
|
417
|
+
recordGrant(db, user.id, drop.client.clientId, ["vault:work:read"]);
|
|
418
|
+
|
|
419
|
+
expect(deleteClient(db, drop.client.clientId)).toBe(true);
|
|
420
|
+
|
|
421
|
+
// The kept client + its grant survive; only the dropped client's
|
|
422
|
+
// grant cascaded.
|
|
423
|
+
expect(getClient(db, keep.client.clientId)).not.toBeNull();
|
|
424
|
+
expect(findGrant(db, user.id, keep.client.clientId)).not.toBeNull();
|
|
425
|
+
expect(getClient(db, drop.client.clientId)).toBeNull();
|
|
426
|
+
expect(findGrant(db, user.id, drop.client.clientId)).toBeNull();
|
|
427
|
+
} finally {
|
|
428
|
+
cleanup();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
344
433
|
describe("isValidRedirectUri", () => {
|
|
345
434
|
test("accepts http and https", () => {
|
|
346
435
|
expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { getClient, registerClient } from "../clients.ts";
|
|
5
6
|
import {
|
|
6
7
|
CORS_PREFLIGHT_HEADERS,
|
|
7
8
|
CORS_RESPONSE_HEADERS,
|
|
@@ -11,7 +12,9 @@ import {
|
|
|
11
12
|
} from "../cors.ts";
|
|
12
13
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
14
|
import { hubFetch } from "../hub-server.ts";
|
|
15
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
14
16
|
import { writeManifest } from "../services-manifest.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
15
18
|
|
|
16
19
|
const GITCOIN_BRAIN_ORIGIN = "https://unforced-dev.github.io";
|
|
17
20
|
const EXAMPLE_ORIGIN = "https://example.com";
|
|
@@ -51,10 +54,12 @@ describe("cors helper module", () => {
|
|
|
51
54
|
expect(CORS_RESPONSE_HEADERS["access-control-expose-headers"]).toContain("WWW-Authenticate");
|
|
52
55
|
});
|
|
53
56
|
|
|
54
|
-
test("CORS_PREFLIGHT_HEADERS announces GET + POST + OPTIONS and standard request headers", () => {
|
|
57
|
+
test("CORS_PREFLIGHT_HEADERS announces GET + POST + DELETE + OPTIONS and standard request headers", () => {
|
|
55
58
|
const methods = CORS_PREFLIGHT_HEADERS["access-control-allow-methods"] ?? "";
|
|
56
59
|
expect(methods).toContain("GET");
|
|
57
60
|
expect(methods).toContain("POST");
|
|
61
|
+
// DELETE for RFC 7592 client deregistration (hub#640).
|
|
62
|
+
expect(methods).toContain("DELETE");
|
|
58
63
|
expect(methods).toContain("OPTIONS");
|
|
59
64
|
const headers = CORS_PREFLIGHT_HEADERS["access-control-allow-headers"] ?? "";
|
|
60
65
|
expect(headers).toContain("Authorization");
|
|
@@ -585,3 +590,135 @@ describe("hubFetch CORS scope discipline — out-of-scope routes stay same-origi
|
|
|
585
590
|
}
|
|
586
591
|
});
|
|
587
592
|
});
|
|
593
|
+
|
|
594
|
+
// hub#640 — confirm the TOP-LEVEL DELETE /oauth/clients/<id> route is wired
|
|
595
|
+
// into the real dispatch (not just the handler in isolation). This is the
|
|
596
|
+
// load-bearing "right prefix" check: the surface remove-flow fires DELETE at
|
|
597
|
+
// exactly this path, and before this route the hub 404'd it. Goes through
|
|
598
|
+
// hubFetch so the dispatch order + the path-prefix branch are exercised.
|
|
599
|
+
describe("hub#640 DELETE /oauth/clients/<id> dispatch (RFC 7592)", () => {
|
|
600
|
+
async function operatorBearer(db: ReturnType<typeof openHubDb>): Promise<string> {
|
|
601
|
+
const user = await createUser(db, "owner", "pw");
|
|
602
|
+
const minted = await signAccessToken(db, {
|
|
603
|
+
sub: user.id,
|
|
604
|
+
scopes: ["parachute:host:admin"],
|
|
605
|
+
audience: "hub",
|
|
606
|
+
clientId: "parachute-hub-spa",
|
|
607
|
+
issuer: ISSUER,
|
|
608
|
+
ttlSeconds: 600,
|
|
609
|
+
});
|
|
610
|
+
return minted.token;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
test("204 + row gone with a valid operator Bearer (the surface remove-flow path)", async () => {
|
|
614
|
+
const h = makeHarness();
|
|
615
|
+
try {
|
|
616
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
617
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
618
|
+
try {
|
|
619
|
+
const bearer = await operatorBearer(db);
|
|
620
|
+
const id = registerClient(db, {
|
|
621
|
+
redirectUris: ["https://app.example/cb"],
|
|
622
|
+
clientName: "Notes",
|
|
623
|
+
}).client.clientId;
|
|
624
|
+
|
|
625
|
+
const res = await hubFetch(h.dir, {
|
|
626
|
+
getDb: () => db,
|
|
627
|
+
issuer: ISSUER,
|
|
628
|
+
manifestPath: h.manifestPath,
|
|
629
|
+
})(
|
|
630
|
+
new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(id)}`, {
|
|
631
|
+
method: "DELETE",
|
|
632
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
633
|
+
}),
|
|
634
|
+
);
|
|
635
|
+
expect(res.status).toBe(204);
|
|
636
|
+
expect(getClient(db, id)).toBeNull();
|
|
637
|
+
} finally {
|
|
638
|
+
db.close();
|
|
639
|
+
}
|
|
640
|
+
} finally {
|
|
641
|
+
h.cleanup();
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("401 without a Bearer — the route is auth-gated, not open", async () => {
|
|
646
|
+
const h = makeHarness();
|
|
647
|
+
try {
|
|
648
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
649
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
650
|
+
try {
|
|
651
|
+
const id = registerClient(db, {
|
|
652
|
+
redirectUris: ["https://app.example/cb"],
|
|
653
|
+
}).client.clientId;
|
|
654
|
+
const res = await hubFetch(h.dir, {
|
|
655
|
+
getDb: () => db,
|
|
656
|
+
issuer: ISSUER,
|
|
657
|
+
manifestPath: h.manifestPath,
|
|
658
|
+
})(
|
|
659
|
+
new Request(`${ISSUER}/oauth/clients/${encodeURIComponent(id)}`, {
|
|
660
|
+
method: "DELETE",
|
|
661
|
+
}),
|
|
662
|
+
);
|
|
663
|
+
expect(res.status).toBe(401);
|
|
664
|
+
// Row survives an unauthenticated DELETE.
|
|
665
|
+
expect(getClient(db, id)).not.toBeNull();
|
|
666
|
+
} finally {
|
|
667
|
+
db.close();
|
|
668
|
+
}
|
|
669
|
+
} finally {
|
|
670
|
+
h.cleanup();
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("404 for an absent client_id through dispatch (matches surface 'not_found')", async () => {
|
|
675
|
+
const h = makeHarness();
|
|
676
|
+
try {
|
|
677
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
678
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
679
|
+
try {
|
|
680
|
+
const bearer = await operatorBearer(db);
|
|
681
|
+
const res = await hubFetch(h.dir, {
|
|
682
|
+
getDb: () => db,
|
|
683
|
+
issuer: ISSUER,
|
|
684
|
+
manifestPath: h.manifestPath,
|
|
685
|
+
})(
|
|
686
|
+
new Request(`${ISSUER}/oauth/clients/no-such-client`, {
|
|
687
|
+
method: "DELETE",
|
|
688
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
expect(res.status).toBe(404);
|
|
692
|
+
// The surface keys off a JSON 404 → hubDeleteStatus "not_found".
|
|
693
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
694
|
+
expect(body.error).toBe("not_found");
|
|
695
|
+
} finally {
|
|
696
|
+
db.close();
|
|
697
|
+
}
|
|
698
|
+
} finally {
|
|
699
|
+
h.cleanup();
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("OPTIONS preflight on the DELETE path advertises DELETE in Allow-Methods", async () => {
|
|
704
|
+
const h = makeHarness();
|
|
705
|
+
try {
|
|
706
|
+
writeManifest({ services: [] }, h.manifestPath);
|
|
707
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
708
|
+
try {
|
|
709
|
+
const res = await hubFetch(h.dir, {
|
|
710
|
+
getDb: () => db,
|
|
711
|
+
issuer: ISSUER,
|
|
712
|
+
manifestPath: h.manifestPath,
|
|
713
|
+
})(preflight("/oauth/clients/some-id", EXAMPLE_ORIGIN));
|
|
714
|
+
expect(res.status).toBe(204);
|
|
715
|
+
expect(res.headers.get("access-control-allow-methods")).toContain("DELETE");
|
|
716
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(EXAMPLE_ORIGIN);
|
|
717
|
+
} finally {
|
|
718
|
+
db.close();
|
|
719
|
+
}
|
|
720
|
+
} finally {
|
|
721
|
+
h.cleanup();
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
});
|
|
@@ -83,6 +83,7 @@ const SUCCESS_BODY = {
|
|
|
83
83
|
grants_dropped: 2,
|
|
84
84
|
user_vaults_removed: 4,
|
|
85
85
|
invites_invalidated: 1,
|
|
86
|
+
vault_cap_removed: true,
|
|
86
87
|
connections_torn_down: 1,
|
|
87
88
|
orphaned_channels: [],
|
|
88
89
|
vault_removed: true,
|
|
@@ -156,6 +157,7 @@ describe("vaultRemove — 200 success", () => {
|
|
|
156
157
|
expect(text).toContain("3");
|
|
157
158
|
expect(text).toContain("user_vaults removed:");
|
|
158
159
|
expect(text).toContain("4");
|
|
160
|
+
expect(text).toContain("storage cap removed:");
|
|
159
161
|
expect(text).toContain("vault removed:");
|
|
160
162
|
});
|
|
161
163
|
|
|
@@ -194,19 +196,35 @@ describe("vaultRemove — 200 success", () => {
|
|
|
194
196
|
});
|
|
195
197
|
});
|
|
196
198
|
|
|
197
|
-
describe("vaultRemove —
|
|
198
|
-
test("
|
|
199
|
+
describe("vaultRemove — last vault (#678: cascade-then-delete, no 409)", () => {
|
|
200
|
+
test("the last vault deletes via the cascade (200) and NEVER spawns parachute-vault directly", async () => {
|
|
199
201
|
await withSpawnSpy(async (spawned) => {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
202
|
+
// The endpoint no longer refuses the last vault — it returns 200 with the
|
|
203
|
+
// cascade summary, identical to any other delete. The CLI just renders it.
|
|
204
|
+
const lastVaultBody = {
|
|
205
|
+
ok: true,
|
|
206
|
+
name: "scratch",
|
|
207
|
+
cascade: {
|
|
208
|
+
tokens_revoked: 2,
|
|
209
|
+
grants_rewritten: 0,
|
|
210
|
+
grants_dropped: 1,
|
|
211
|
+
user_vaults_removed: 1,
|
|
212
|
+
invites_invalidated: 0,
|
|
213
|
+
vault_cap_removed: true,
|
|
214
|
+
connections_torn_down: 0,
|
|
215
|
+
orphaned_channels: [],
|
|
216
|
+
vault_removed: true,
|
|
217
|
+
module_restarted: true,
|
|
208
218
|
},
|
|
209
|
-
|
|
219
|
+
warnings: [
|
|
220
|
+
{
|
|
221
|
+
step: "last_vault",
|
|
222
|
+
detail:
|
|
223
|
+
"the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
};
|
|
227
|
+
const { fetch, calls } = fakeFetch([{ status: 200, body: lastVaultBody }]);
|
|
210
228
|
const sinks = makeSinks();
|
|
211
229
|
const code = await vaultRemove(["scratch"], {
|
|
212
230
|
resolveBearer: async () => BEARER,
|
|
@@ -214,17 +232,20 @@ describe("vaultRemove — 409 last_vault GUARDRAIL", () => {
|
|
|
214
232
|
log: sinks.log,
|
|
215
233
|
logError: sinks.logError,
|
|
216
234
|
});
|
|
217
|
-
//
|
|
218
|
-
expect(code).
|
|
219
|
-
// Exactly ONE fetch (the DELETE) —
|
|
235
|
+
// 200 → success exit; the cascade did its work.
|
|
236
|
+
expect(code).toBe(0);
|
|
237
|
+
// Exactly ONE fetch (the DELETE) — the cascade runs server-side over loopback.
|
|
220
238
|
expect(calls).toHaveLength(1);
|
|
221
239
|
expect(calls[0]?.method).toBe("DELETE");
|
|
222
|
-
// The load-bearing invariant:
|
|
240
|
+
// The load-bearing invariant still holds: the CLI never spawns
|
|
241
|
+
// `parachute-vault` itself — destruction goes through the hub endpoint.
|
|
223
242
|
expect(spawned.count).toBe(0);
|
|
224
|
-
//
|
|
225
|
-
const
|
|
226
|
-
expect(
|
|
227
|
-
expect(
|
|
243
|
+
// The cascade summary renders, including the last_vault heads-up warning.
|
|
244
|
+
const text = sinks.text();
|
|
245
|
+
expect(text).toContain("tokens revoked:");
|
|
246
|
+
expect(text).toContain("vault removed:");
|
|
247
|
+
expect(text).toContain("last_vault");
|
|
248
|
+
expect(text).toContain("auto_create: false");
|
|
228
249
|
});
|
|
229
250
|
});
|
|
230
251
|
});
|
package/src/admin-clients.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* without round-tripping through the `/oauth/authorize` flow (whose
|
|
5
5
|
* `POST /oauth/authorize/approve` requires a `return_to` authorize URL).
|
|
6
6
|
*
|
|
7
|
-
* GET
|
|
8
|
-
* POST
|
|
7
|
+
* GET /api/oauth/clients/<client_id> client details
|
|
8
|
+
* POST /api/oauth/clients/<client_id>/approve flip status to approved
|
|
9
|
+
* DELETE /oauth/clients/<client_id> deregister (RFC 7592) — note
|
|
10
|
+
* the TOP-LEVEL prefix, see
|
|
11
|
+
* handleDeleteClient
|
|
9
12
|
*
|
|
10
13
|
* Both gated by `parachute:host:admin` Bearer (same shape as /api/grants,
|
|
11
14
|
* /api/auth/tokens, etc.). The SPA mints one via the session cookie at
|
|
@@ -54,7 +57,7 @@ import {
|
|
|
54
57
|
requireScope,
|
|
55
58
|
} from "./admin-auth.ts";
|
|
56
59
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
57
|
-
import { approveClient, getClient } from "./clients.ts";
|
|
60
|
+
import { approveClient, deleteClient, getClient } from "./clients.ts";
|
|
58
61
|
import { isSafeAuthorizeReturnTo } from "./oauth-handlers.ts";
|
|
59
62
|
|
|
60
63
|
export interface AdminClientsDeps {
|
|
@@ -177,6 +180,55 @@ export async function handleApproveClient(
|
|
|
177
180
|
});
|
|
178
181
|
}
|
|
179
182
|
|
|
183
|
+
/**
|
|
184
|
+
* RFC 7592 Dynamic Client Registration *deletion* (deregistration).
|
|
185
|
+
*
|
|
186
|
+
* DELETE /oauth/clients/<client_id> remove the client + its cascade
|
|
187
|
+
*
|
|
188
|
+
* Mounted at the TOP-LEVEL `/oauth/clients/` prefix (NOT under `/api/...`)
|
|
189
|
+
* because that's the path parachute-surface's remove-flow actually calls
|
|
190
|
+
* (`packages/surface-host/src/dcr.ts` → `DELETE <hub>/oauth/clients/<id>`),
|
|
191
|
+
* carrying the operator token as a Bearer. Before this route existed the
|
|
192
|
+
* hub 404'd every such DELETE, so every Notes/Claude reconnect orphaned a
|
|
193
|
+
* `clients` row in the operator's DB (closes hub#640, 4/5 boxes — the GC
|
|
194
|
+
* reaper for legacy orphans is a separate follow-up).
|
|
195
|
+
*
|
|
196
|
+
* Auth mirrors `handleGetClient`: `parachute:host:admin` Bearer via
|
|
197
|
+
* `requireScope`. Returns 204 (no content) on a successful delete, 404 when
|
|
198
|
+
* the client isn't registered — the same shape the surface already tolerates
|
|
199
|
+
* (`hubDeleteStatus: "ok"` on 200/204, `"not_found"` on a JSON 404).
|
|
200
|
+
*
|
|
201
|
+
* Audit: emits a `client deleted: ...` line in the same `key=value` shape as
|
|
202
|
+
* the `client approved: ...` line, so cross-machine "who removed this client"
|
|
203
|
+
* is greppable in hub.log.
|
|
204
|
+
*/
|
|
205
|
+
export async function handleDeleteClient(
|
|
206
|
+
req: Request,
|
|
207
|
+
clientId: string,
|
|
208
|
+
deps: AdminClientsDeps,
|
|
209
|
+
): Promise<Response> {
|
|
210
|
+
if (req.method !== "DELETE") {
|
|
211
|
+
return jsonError(405, "method_not_allowed", "use DELETE");
|
|
212
|
+
}
|
|
213
|
+
let ctx: AdminAuthContext;
|
|
214
|
+
try {
|
|
215
|
+
ctx = await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return adminAuthErrorResponse(err as AdminAuthError);
|
|
218
|
+
}
|
|
219
|
+
// Capture the name BEFORE deleting so the audit line can carry it.
|
|
220
|
+
const before = getClient(deps.db, clientId);
|
|
221
|
+
const removed = deleteClient(deps.db, clientId);
|
|
222
|
+
if (!removed) {
|
|
223
|
+
return jsonError(404, "not_found", `no client registered with id ${clientId}`);
|
|
224
|
+
}
|
|
225
|
+
console.log(
|
|
226
|
+
`client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=${ctx.sub}`,
|
|
227
|
+
);
|
|
228
|
+
// 204 No Content — RFC 7592 §2.3 prescribes 204 for a successful delete.
|
|
229
|
+
return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
230
|
+
}
|
|
231
|
+
|
|
180
232
|
interface ApproveClientResponse {
|
|
181
233
|
client_id: string;
|
|
182
234
|
status: "approved";
|
package/src/admin-vaults.ts
CHANGED
|
@@ -630,15 +630,22 @@ export function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
630
630
|
*
|
|
631
631
|
* Refusals:
|
|
632
632
|
* - unknown vault → 404;
|
|
633
|
-
* - LAST remaining vault → 409. Vault's boot auto-creates `default` at
|
|
634
|
-
* zero vaults, so deleting the last one would silently resurrect a fresh
|
|
635
|
-
* `default` (with a fresh global API key) — refusing sidesteps the
|
|
636
|
-
* resurrection class entirely. The CLI (`parachute-vault remove`) is the
|
|
637
|
-
* escape hatch for an operator who really means it.
|
|
638
633
|
* - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
|
|
639
634
|
* squatted `admin`/`new`/`assets` vault created before the B2h
|
|
640
635
|
* reservation must be removable through this endpoint.
|
|
641
636
|
*
|
|
637
|
+
* Last-vault handling (#678): deleting the LAST remaining vault runs the SAME
|
|
638
|
+
* cascade-then-delete as any other vault — it is NOT refused. The old 409 that
|
|
639
|
+
* steered the operator to the raw `parachute-vault remove` CLI was a
|
|
640
|
+
* correctness defect: that escape hatch SKIPS this cascade, orphaning tokens +
|
|
641
|
+
* grants that named the last vault. The resurrection risk the refusal once
|
|
642
|
+
* guarded (vault boot auto-creating a fresh-credentialed first vault at zero
|
|
643
|
+
* vaults) is handled downstream instead: the vault CLI writes an
|
|
644
|
+
* `auto_create: false` marker on last-vault removal and the vault boot gate
|
|
645
|
+
* honors it, so the server won't silently resurrect. Detection stays
|
|
646
|
+
* count-based + name-agnostic (no `name === "default"` special case); the last
|
|
647
|
+
* vault just adds a `last_vault` warning to the 200 response.
|
|
648
|
+
*
|
|
642
649
|
* Cascade, in order (identity first, mechanics last — revocation is the safe
|
|
643
650
|
* direction if a later step fails):
|
|
644
651
|
* 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
|
|
@@ -661,6 +668,15 @@ export function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
661
668
|
* Response: 200 with a structured per-step summary (counts +
|
|
662
669
|
* `orphaned_channels` + warnings). A mechanics failure responds 500 with
|
|
663
670
|
* the partial summary — the identity artifacts already revoked stay revoked.
|
|
671
|
+
*
|
|
672
|
+
* Bounded residual: the cascade revokes every *registered* token row naming
|
|
673
|
+
* the vault, but an UNREGISTERED interactive-mint (a host-admin browser
|
|
674
|
+
* session minting a short-lived vault token at ≤10-min TTL — see
|
|
675
|
+
* REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts) that was
|
|
676
|
+
* issued just before the delete leaves no registry row to sweep. Such a token
|
|
677
|
+
* stays valid for at most its remaining ≤10-min TTL, against a vault whose
|
|
678
|
+
* daemon is evicted in step 7 anyway. Same bound the auth-codes note below
|
|
679
|
+
* relies on; not eliminated, just bounded.
|
|
664
680
|
*/
|
|
665
681
|
export async function handleDeleteVault(
|
|
666
682
|
req: Request,
|
|
@@ -714,16 +730,20 @@ export async function handleDeleteVault(
|
|
|
714
730
|
return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
|
|
715
731
|
}
|
|
716
732
|
|
|
717
|
-
// Last-vault
|
|
733
|
+
// Last-vault detection (count-based, name-agnostic). Deleting the LAST
|
|
734
|
+
// vault used to refuse with 409 and steer the operator to the raw
|
|
735
|
+
// `parachute-vault remove` CLI — but that path SKIPS this whole identity
|
|
736
|
+
// cascade, orphaning tokens + grants that named the vault. The resurrection
|
|
737
|
+
// risk the refusal guarded against is already handled downstream: the vault
|
|
738
|
+
// CLI writes an `auto_create: false` marker when it removes the last vault
|
|
739
|
+
// (vault `cli.ts` cmdRemove) and the vault boot gate honors it
|
|
740
|
+
// (`bootAutoCreateAllowed` in vault `config.ts`), so the server won't
|
|
741
|
+
// silently resurrect a fresh-credentialed first vault. We therefore run the
|
|
742
|
+
// cascade-then-delete for the last vault exactly as for any other — the
|
|
743
|
+
// count is informational only (no refusal).
|
|
718
744
|
const instanceNames = listVaultInstanceNames(manifestPath);
|
|
719
745
|
instanceNames.delete(name);
|
|
720
|
-
|
|
721
|
-
return jsonError(
|
|
722
|
-
409,
|
|
723
|
-
"last_vault",
|
|
724
|
-
`"${name}" is the last vault on this hub. Vault's boot auto-creates "default" at zero vaults, so deleting the last one would silently resurrect it with fresh credentials. Create another vault first, or use the CLI (parachute-vault remove ${name} --yes) if you really mean to empty the hub.`,
|
|
725
|
-
);
|
|
726
|
-
}
|
|
746
|
+
const isLastVault = instanceNames.size === 0;
|
|
727
747
|
|
|
728
748
|
const summary = emptyCascadeSummary();
|
|
729
749
|
const warnings: { step: string; detail: string }[] = [];
|
|
@@ -865,6 +885,18 @@ export async function handleDeleteVault(
|
|
|
865
885
|
);
|
|
866
886
|
}
|
|
867
887
|
|
|
888
|
+
// Last-vault heads-up. The vault CLI's remove wrote `auto_create: false`, so
|
|
889
|
+
// the next vault boot won't resurrect a fresh-credentialed first vault — the
|
|
890
|
+
// hub is now deliberately empty. Surface that so the operator knows to create
|
|
891
|
+
// one when they want the hub serving again.
|
|
892
|
+
if (isLastVault) {
|
|
893
|
+
warnings.push({
|
|
894
|
+
step: "last_vault",
|
|
895
|
+
detail:
|
|
896
|
+
"the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
868
900
|
// --- 7. Daemon eviction: supervisor-restart the vault module. -------------
|
|
869
901
|
if (deps.restartVaultModule) {
|
|
870
902
|
try {
|
package/src/clients.ts
CHANGED
|
@@ -203,6 +203,41 @@ export function getClient(db: Database, clientId: string): OAuthClient | null {
|
|
|
203
203
|
return row ? rowToClient(row) : null;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Delete an OAuth client and everything that references it. Returns true when
|
|
208
|
+
* a client row was removed, false when no such client existed.
|
|
209
|
+
*
|
|
210
|
+
* Backs the RFC 7592 `DELETE /oauth/clients/<id>` deregistration endpoint
|
|
211
|
+
* (closes hub#640): parachute-surface fires a best-effort DELETE on every
|
|
212
|
+
* Notes/Claude remove-flow, so without this helper + route every reconnect
|
|
213
|
+
* orphaned a `clients` row in the operator's hub DB.
|
|
214
|
+
*
|
|
215
|
+
* CASCADE-BY-HAND. `auth_codes.client_id` and `grants.client_id` both
|
|
216
|
+
* `REFERENCES clients(client_id)` with NO `ON DELETE CASCADE`, and the hub
|
|
217
|
+
* opens its DB with `PRAGMA foreign_keys = ON` — so a bare
|
|
218
|
+
* `DELETE FROM clients` while a dependent auth_code or grant row exists
|
|
219
|
+
* throws a FOREIGN KEY constraint violation. We delete the dependents FIRST,
|
|
220
|
+
* then the client row, all inside a single transaction so a mid-cascade
|
|
221
|
+
* failure rolls the whole thing back (no half-deleted client). Modelled on
|
|
222
|
+
* `grants.revokeGrant` + the vault-delete cascade in `admin-vaults`.
|
|
223
|
+
*
|
|
224
|
+
* Note on tokens: access tokens already minted for this client are NOT
|
|
225
|
+
* touched here (the `tokens` registry is keyed by jti, not client_id, and
|
|
226
|
+
* carries no FK to `clients`). They expire on their own; an operator who
|
|
227
|
+
* wants to kill live sessions runs `/oauth/revoke` separately. Same posture
|
|
228
|
+
* `revokeGrant` documents.
|
|
229
|
+
*/
|
|
230
|
+
export function deleteClient(db: Database, clientId: string): boolean {
|
|
231
|
+
return db.transaction(() => {
|
|
232
|
+
// Delete dependents first — FK ON, no cascade, so the client row can't
|
|
233
|
+
// go while an auth_code or grant still points at it.
|
|
234
|
+
db.prepare("DELETE FROM auth_codes WHERE client_id = ?").run(clientId);
|
|
235
|
+
db.prepare("DELETE FROM grants WHERE client_id = ?").run(clientId);
|
|
236
|
+
const res = db.prepare("DELETE FROM clients WHERE client_id = ?").run(clientId);
|
|
237
|
+
return res.changes > 0;
|
|
238
|
+
})();
|
|
239
|
+
}
|
|
240
|
+
|
|
206
241
|
/**
|
|
207
242
|
* Returns the registered redirect URI matching `candidate` exactly, or throws.
|
|
208
243
|
* RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no
|
package/src/commands/auth.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { createInterface } from "node:readline/promises";
|
|
28
|
-
import { approveClient, getClient, listClientsByStatus } from "../clients.ts";
|
|
28
|
+
import { approveClient, deleteClient, getClient, listClientsByStatus } from "../clients.ts";
|
|
29
29
|
import { CONFIG_DIR } from "../config.ts";
|
|
30
30
|
import { readExposeState } from "../expose-state.ts";
|
|
31
31
|
import { listGrantsForUser, revokeGrant } from "../grants.ts";
|
|
@@ -96,6 +96,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
|
96
96
|
"revoke-token",
|
|
97
97
|
"pending-clients",
|
|
98
98
|
"approve-client",
|
|
99
|
+
"revoke-client",
|
|
99
100
|
"list-grants",
|
|
100
101
|
"revoke-grant",
|
|
101
102
|
]);
|
|
@@ -135,6 +136,9 @@ Usage:
|
|
|
135
136
|
exits 0.
|
|
136
137
|
parachute auth pending-clients List OAuth clients awaiting approval
|
|
137
138
|
parachute auth approve-client <id> Approve a pending OAuth client
|
|
139
|
+
parachute auth revoke-client <id> Deregister (delete) an OAuth client,
|
|
140
|
+
cascading its grants + auth codes
|
|
141
|
+
(RFC 7592 deregistration)
|
|
138
142
|
parachute auth list-grants [--username <name>]
|
|
139
143
|
Show OAuth scope grants on record
|
|
140
144
|
parachute auth revoke-grant <client_id> [--username <name>]
|
|
@@ -619,6 +623,44 @@ function runApproveClient(args: readonly string[], deps: AuthDeps): number {
|
|
|
619
623
|
}
|
|
620
624
|
}
|
|
621
625
|
|
|
626
|
+
/**
|
|
627
|
+
* `parachute auth revoke-client <id>` — RFC 7592 deregistration from the
|
|
628
|
+
* terminal. The CLI complement to the `DELETE /oauth/clients/<id>` route
|
|
629
|
+
* parachute-surface fires automatically; an operator runs this to clean up
|
|
630
|
+
* an orphaned / stale client by hand (closes hub#640). Calls the
|
|
631
|
+
* `deleteClient` cascade helper directly (same db, no round-trip through the
|
|
632
|
+
* HTTP route) and emits the same `client deleted:` audit line the route does
|
|
633
|
+
* so browser-driven and terminal-driven deletions are uniformly greppable in
|
|
634
|
+
* hub.log. `remover_sub=cli` distinguishes the terminal path (which has no
|
|
635
|
+
* authenticated JWT subject) from the route's `remover_sub=<jwt sub>`.
|
|
636
|
+
*/
|
|
637
|
+
function runRevokeClient(args: readonly string[], deps: AuthDeps): number {
|
|
638
|
+
const clientId = args[0];
|
|
639
|
+
if (!clientId) {
|
|
640
|
+
console.error("parachute auth revoke-client: missing client_id argument");
|
|
641
|
+
console.error("usage: parachute auth revoke-client <client_id>");
|
|
642
|
+
return 1;
|
|
643
|
+
}
|
|
644
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
645
|
+
try {
|
|
646
|
+
// Read the name before deleting so the audit line + confirmation can
|
|
647
|
+
// carry it. getClient also gives us the "exists?" answer for the 404 path.
|
|
648
|
+
const before = getClient(db, clientId);
|
|
649
|
+
const removed = deleteClient(db, clientId);
|
|
650
|
+
if (!removed) {
|
|
651
|
+
console.error(`no OAuth client registered with client_id "${clientId}"`);
|
|
652
|
+
return 1;
|
|
653
|
+
}
|
|
654
|
+
console.log(
|
|
655
|
+
`client deleted: client_id=${clientId} client_name=${before?.clientName ?? ""} remover_sub=cli`,
|
|
656
|
+
);
|
|
657
|
+
console.log(`Deregistered OAuth client "${clientId}" (grants + auth codes cascaded).`);
|
|
658
|
+
return 0;
|
|
659
|
+
} finally {
|
|
660
|
+
db.close();
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
622
664
|
interface UsernameFlag {
|
|
623
665
|
username?: string;
|
|
624
666
|
rest: string[];
|
|
@@ -1405,6 +1447,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1405
1447
|
return 1;
|
|
1406
1448
|
}
|
|
1407
1449
|
}
|
|
1450
|
+
if (sub === "revoke-client") {
|
|
1451
|
+
try {
|
|
1452
|
+
return runRevokeClient(args.slice(1), normalized);
|
|
1453
|
+
} catch (err) {
|
|
1454
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1455
|
+
console.error(`parachute auth revoke-client: ${msg}`);
|
|
1456
|
+
return 1;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1408
1459
|
if (sub === "list-grants") {
|
|
1409
1460
|
try {
|
|
1410
1461
|
return runListGrants(args.slice(1), normalized);
|
|
@@ -31,14 +31,18 @@
|
|
|
31
31
|
* `parachute:host:admin` — exactly the scope the endpoint gates on. This is the
|
|
32
32
|
* same read-never-mint credential path `parachute start/stop/restart <svc>` use.
|
|
33
33
|
*
|
|
34
|
-
* ##
|
|
34
|
+
* ## Last-vault handling (#678)
|
|
35
35
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
36
|
+
* The last/only vault is deleted IDENTICALLY to any other vault: the endpoint
|
|
37
|
+
* runs the full cascade-then-delete and returns 200. There is no special-case
|
|
38
|
+
* here. (Older builds refused the last vault with a `409 last_vault` and steered
|
|
39
|
+
* the operator to the raw `parachute-vault remove --yes` — but that escape hatch
|
|
40
|
+
* SKIPS the cascade, orphaning the very identity artifacts B3 set out to clean
|
|
41
|
+
* up. hub#678 removed that refusal: vault's boot can no longer silently
|
|
42
|
+
* resurrect a fresh first vault because vault's CLI writes an
|
|
43
|
+
* `auto_create: false` marker on last-vault removal and the boot gate honors
|
|
44
|
+
* it.) This command therefore needs no 409 branch — the 200 path renders the
|
|
45
|
+
* cascade summary for the last vault just like every other delete.
|
|
42
46
|
*/
|
|
43
47
|
|
|
44
48
|
import { CONFIG_DIR } from "../config.ts";
|
|
@@ -56,8 +60,9 @@ import {
|
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
62
|
* Injectable seams. Production wires the real operator-token bearer resolver +
|
|
59
|
-
* the global `fetch`; tests inject fakes to assert the request shape +
|
|
60
|
-
*
|
|
63
|
+
* the global `fetch`; tests inject fakes to assert the request shape + that
|
|
64
|
+
* destruction always goes through the hub endpoint (never a direct
|
|
65
|
+
* `parachute-vault` spawn) without a live hub or a real socket.
|
|
61
66
|
*/
|
|
62
67
|
export interface VaultRemoveDeps {
|
|
63
68
|
/**
|
|
@@ -84,6 +89,7 @@ interface CascadeSummaryWire {
|
|
|
84
89
|
grants_dropped?: number;
|
|
85
90
|
user_vaults_removed?: number;
|
|
86
91
|
invites_invalidated?: number;
|
|
92
|
+
vault_cap_removed?: boolean;
|
|
87
93
|
connections_torn_down?: number;
|
|
88
94
|
orphaned_channels?: unknown;
|
|
89
95
|
vault_removed?: boolean;
|
|
@@ -151,6 +157,7 @@ function renderCascadeSummary(
|
|
|
151
157
|
log(` grants dropped: ${n(c.grants_dropped)}`);
|
|
152
158
|
log(` user_vaults removed: ${n(c.user_vaults_removed)}`);
|
|
153
159
|
log(` invites invalidated: ${n(c.invites_invalidated)}`);
|
|
160
|
+
log(` storage cap removed: ${c.vault_cap_removed === true ? "yes" : "no"}`);
|
|
154
161
|
log(` connections torn down: ${n(c.connections_torn_down)}`);
|
|
155
162
|
log(` vault removed: ${c.vault_removed === true ? "yes" : "no"}`);
|
|
156
163
|
log(` vault module restarted:${c.module_restarted === true ? " yes" : " no"}`);
|
|
@@ -302,21 +309,6 @@ export async function vaultRemove(args: string[], deps: VaultRemoveDeps = {}): P
|
|
|
302
309
|
return 0;
|
|
303
310
|
}
|
|
304
311
|
|
|
305
|
-
if (res.status === 409 && error === "last_vault") {
|
|
306
|
-
// CRITICAL GUARDRAIL: print + exit non-zero. Do NOT fall through to spawning
|
|
307
|
-
// `parachute-vault` — that would re-open the orphaned-identity bug B3 closes.
|
|
308
|
-
logError(`parachute vault remove: ${error_description}`);
|
|
309
|
-
logError("");
|
|
310
|
-
logError(
|
|
311
|
-
`The raw mechanics-only path \`parachute-vault remove ${name} --yes\` can delete the last vault,`,
|
|
312
|
-
);
|
|
313
|
-
logError(
|
|
314
|
-
"but it SKIPS the identity cascade — live tokens, grants, and user_vaults rows for that",
|
|
315
|
-
);
|
|
316
|
-
logError("vault would be left orphaned. Create another vault first if you can.");
|
|
317
|
-
return 1;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
312
|
if (res.status === 400 && error === "confirm_mismatch") {
|
|
321
313
|
// Pass the hub's confirm message through.
|
|
322
314
|
logError(`parachute vault remove: ${error_description}`);
|
package/src/cors.ts
CHANGED
|
@@ -89,8 +89,9 @@
|
|
|
89
89
|
* leaking the wrong ACAO and breaking CORS in unpredictable ways.
|
|
90
90
|
* Critical for cache correctness.
|
|
91
91
|
*
|
|
92
|
-
* Access-Control-Allow-Methods: GET, POST, OPTIONS
|
|
93
|
-
* The union of methods the in-scope route family supports
|
|
92
|
+
* Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
|
|
93
|
+
* The union of methods the in-scope route family supports (DELETE for the
|
|
94
|
+
* RFC 7592 `DELETE /oauth/clients/<id>` deregistration, hub#640). Per-route
|
|
94
95
|
* could be narrower (e.g. /oauth/token is POST-only), but advertising
|
|
95
96
|
* the union is the simpler shape and browsers don't enforce a per-route
|
|
96
97
|
* check anyway — the *actual* request method gates execution at the
|
|
@@ -137,7 +138,10 @@ const CORS_STATIC_RESPONSE_HEADERS: Readonly<Record<string, string>> = {
|
|
|
137
138
|
* `corsPreflightResponse`.
|
|
138
139
|
*/
|
|
139
140
|
const CORS_STATIC_PREFLIGHT_HEADERS: Readonly<Record<string, string>> = {
|
|
140
|
-
|
|
141
|
+
// DELETE is in the union for RFC 7592 client deregistration
|
|
142
|
+
// (`DELETE /oauth/clients/<id>`, hub#640). A cross-origin browser caller
|
|
143
|
+
// (vs the server-side surface daemon) would otherwise fail the preflight.
|
|
144
|
+
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
|
|
141
145
|
"access-control-allow-headers": "Authorization, Content-Type, X-Requested-With",
|
|
142
146
|
"access-control-max-age": "86400",
|
|
143
147
|
};
|
package/src/hub-server.ts
CHANGED
|
@@ -171,7 +171,7 @@ import {
|
|
|
171
171
|
handleOAuthGrantCallback,
|
|
172
172
|
} from "./admin-agent-grants.ts";
|
|
173
173
|
import { handleAgentToken } from "./admin-agent-token.ts";
|
|
174
|
-
import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
|
|
174
|
+
import { handleApproveClient, handleDeleteClient, handleGetClient } from "./admin-clients.ts";
|
|
175
175
|
import {
|
|
176
176
|
type ConnectionsDeps,
|
|
177
177
|
handleConnections,
|
|
@@ -2767,6 +2767,33 @@ export function hubFetch(
|
|
|
2767
2767
|
return applyCorsHeaders(req, await handleRevoke(getDb(), req, oauthDeps(req)));
|
|
2768
2768
|
}
|
|
2769
2769
|
|
|
2770
|
+
// RFC 7592 client deregistration: DELETE /oauth/clients/<id> (hub#640).
|
|
2771
|
+
// Mounted at this TOP-LEVEL `/oauth/clients/` prefix — NOT under
|
|
2772
|
+
// `/api/oauth/clients/` — because that's the path parachute-surface's
|
|
2773
|
+
// remove-flow actually calls (`packages/surface-host/src/dcr.ts` fires a
|
|
2774
|
+
// best-effort DELETE on every Notes/Claude reconnect, carrying the
|
|
2775
|
+
// operator token as a Bearer). Without it the hub 404'd every such
|
|
2776
|
+
// DELETE and orphaned a `clients` row per reconnect. Operator-bearer-
|
|
2777
|
+
// gated (parachute:host:admin) inside handleDeleteClient; 204 on delete,
|
|
2778
|
+
// 404 if absent. CORS-wrapped + OPTIONS-preflighted like its OAuth
|
|
2779
|
+
// siblings (the top-of-dispatch isCorsAllowedRoute("/oauth/") preempts
|
|
2780
|
+
// the preflight). The GET/approve sub-paths stay on `/api/oauth/clients/`
|
|
2781
|
+
// (the SPA-facing admin surface) below.
|
|
2782
|
+
if (pathname.startsWith("/oauth/clients/")) {
|
|
2783
|
+
if (!getDb) return applyCorsHeaders(req, dbNotConfigured());
|
|
2784
|
+
const clientId = decodeURIComponent(pathname.slice("/oauth/clients/".length));
|
|
2785
|
+
if (!clientId || clientId.includes("/")) {
|
|
2786
|
+
return applyCorsHeaders(req, new Response("not found", { status: 404 }));
|
|
2787
|
+
}
|
|
2788
|
+
return applyCorsHeaders(
|
|
2789
|
+
req,
|
|
2790
|
+
await handleDeleteClient(req, clientId, {
|
|
2791
|
+
db: getDb(),
|
|
2792
|
+
issuer: oauthDeps(req).issuer,
|
|
2793
|
+
}),
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2770
2797
|
// Agent-connector OAuth-client callback (Phase 4b-2). The operator's
|
|
2771
2798
|
// browser is redirected here by a REMOTE issuer after consenting to a
|
|
2772
2799
|
// `kind:mcp` grant. Standalone server-rendered route — NOT under /admin/*,
|