@openparachute/hub 0.7.4-rc.15 → 0.7.4-rc.17
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__/api-account-2fa.test.ts +72 -0
- package/src/__tests__/auth.test.ts +267 -0
- package/src/__tests__/clients.test.ts +209 -0
- package/src/api-account-2fa.ts +23 -1
- package/src/clients.ts +129 -0
- package/src/commands/auth.ts +212 -1
- package/src/hub-db.ts +14 -0
- package/src/rate-limit.ts +28 -0
package/package.json
CHANGED
|
@@ -312,6 +312,78 @@ describe("/api/account/2fa start + confirm", () => {
|
|
|
312
312
|
const body = (await res.json()) as { error: string };
|
|
313
313
|
expect(body.error).toBe("setup_expired");
|
|
314
314
|
});
|
|
315
|
+
|
|
316
|
+
test("confirm is rate-limited after 10 attempts → 429 (lenient, #712)", async () => {
|
|
317
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
318
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
319
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
320
|
+
// 10 honest mistypes are admitted (each 400 invalid_code) — the lenient
|
|
321
|
+
// bucket doesn't punish a fumbling enroller.
|
|
322
|
+
for (let i = 0; i < 10; i++) {
|
|
323
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
324
|
+
__csrf: TEST_CSRF,
|
|
325
|
+
secret,
|
|
326
|
+
code: "000000",
|
|
327
|
+
});
|
|
328
|
+
expect(r.status).toBe(400);
|
|
329
|
+
}
|
|
330
|
+
// 11th is denied by the limiter BEFORE the code is checked.
|
|
331
|
+
const denied = await post("/2fa/confirm", cookie, {
|
|
332
|
+
__csrf: TEST_CSRF,
|
|
333
|
+
secret,
|
|
334
|
+
code: "000000",
|
|
335
|
+
});
|
|
336
|
+
expect(denied.status).toBe(429);
|
|
337
|
+
const body = (await denied.json()) as { error: string };
|
|
338
|
+
expect(body.error).toBe("too_many_attempts");
|
|
339
|
+
expect(denied.headers.get("retry-after")).toBeTruthy();
|
|
340
|
+
// The grind never touched enrollment.
|
|
341
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("malformed-secret POSTs don't burn the confirm budget (#712)", async () => {
|
|
345
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
346
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
347
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
348
|
+
// 10 junk POSTs are rejected by the format guard BEFORE the limiter runs.
|
|
349
|
+
for (let i = 0; i < 10; i++) {
|
|
350
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
351
|
+
__csrf: TEST_CSRF,
|
|
352
|
+
secret: "not-base32!!",
|
|
353
|
+
code: "000000",
|
|
354
|
+
});
|
|
355
|
+
expect(r.status).toBe(400);
|
|
356
|
+
}
|
|
357
|
+
// Budget untouched — the legit live code still enrolls on the next attempt.
|
|
358
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
359
|
+
__csrf: TEST_CSRF,
|
|
360
|
+
secret,
|
|
361
|
+
code: liveCode(secret),
|
|
362
|
+
});
|
|
363
|
+
expect(ok.status).toBe(200);
|
|
364
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("a few mistypes then the live code within budget still enrolls (lenient)", async () => {
|
|
368
|
+
const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
|
|
369
|
+
const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
|
|
370
|
+
const { secret } = (await startRes.json()) as { secret: string };
|
|
371
|
+
for (let i = 0; i < 3; i++) {
|
|
372
|
+
const r = await post("/2fa/confirm", cookie, {
|
|
373
|
+
__csrf: TEST_CSRF,
|
|
374
|
+
secret,
|
|
375
|
+
code: "000000",
|
|
376
|
+
});
|
|
377
|
+
expect(r.status).toBe(400);
|
|
378
|
+
}
|
|
379
|
+
const ok = await post("/2fa/confirm", cookie, {
|
|
380
|
+
__csrf: TEST_CSRF,
|
|
381
|
+
secret,
|
|
382
|
+
code: liveCode(secret),
|
|
383
|
+
});
|
|
384
|
+
expect(ok.status).toBe(200);
|
|
385
|
+
expect(isTotpEnrolled(harness.db, userId)).toBe(true);
|
|
386
|
+
});
|
|
315
387
|
});
|
|
316
388
|
|
|
317
389
|
describe("/api/account/2fa/disable", () => {
|
|
@@ -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 { issueAuthCode } from "../auth-codes.ts";
|
|
5
6
|
import { registerClient } from "../clients.ts";
|
|
6
7
|
import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
|
|
7
8
|
import { findGrant, recordGrant } from "../grants.ts";
|
|
@@ -269,6 +270,16 @@ describe("authHelp", () => {
|
|
|
269
270
|
expect(h).toContain("parachute auth list-users");
|
|
270
271
|
expect(h).toContain("parachute auth 2fa");
|
|
271
272
|
expect(h).toContain("parachute auth rotate-key");
|
|
273
|
+
expect(h).toContain("parachute auth reap-clients");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("reap-clients help documents dry-run-by-default + the conservative gate (#640)", () => {
|
|
277
|
+
expect(h).toContain("reap-clients");
|
|
278
|
+
expect(h).toContain("Dry-run by DEFAULT");
|
|
279
|
+
expect(h).toContain("--apply");
|
|
280
|
+
expect(h).toContain("--older-than");
|
|
281
|
+
expect(h).toContain("PROVABLY-DEAD");
|
|
282
|
+
expect(h).toContain("#640");
|
|
272
283
|
});
|
|
273
284
|
|
|
274
285
|
test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
|
|
@@ -918,6 +929,262 @@ describe("parachute auth revoke-client", () => {
|
|
|
918
929
|
});
|
|
919
930
|
});
|
|
920
931
|
|
|
932
|
+
// closes #640 — OAuth client GC reaper. Dry-run by default; only provably-dead
|
|
933
|
+
// clients are reapable. The deep gate coverage lives in clients.test.ts; these
|
|
934
|
+
// exercise the CLI surface (dry-run safety, --apply, --json, empty case).
|
|
935
|
+
describe("parachute auth reap-clients", () => {
|
|
936
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
937
|
+
|
|
938
|
+
/** Register a client `daysAgo` days before now (relative to wall clock). */
|
|
939
|
+
function oldClient(db: ReturnType<typeof openHubDb>, daysAgo: number, name?: string): string {
|
|
940
|
+
const when = new Date(Date.now() - daysAgo * DAY_MS);
|
|
941
|
+
return registerClient(db, {
|
|
942
|
+
redirectUris: ["https://app.example/cb"],
|
|
943
|
+
...(name !== undefined ? { clientName: name } : {}),
|
|
944
|
+
now: () => when,
|
|
945
|
+
}).client.clientId;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
test("empty case: clean message, exit 0, no false alarm", async () => {
|
|
949
|
+
const tmp = makeTmp();
|
|
950
|
+
try {
|
|
951
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
952
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
953
|
+
expect(code).toBe(0);
|
|
954
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
955
|
+
} finally {
|
|
956
|
+
tmp.cleanup();
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("dry-run by DEFAULT lists candidates but deletes NOTHING", async () => {
|
|
961
|
+
const tmp = makeTmp();
|
|
962
|
+
let deadId: string;
|
|
963
|
+
try {
|
|
964
|
+
const db = openHubDb(tmp.dbPath);
|
|
965
|
+
try {
|
|
966
|
+
deadId = oldClient(db, 60, "DeadApp");
|
|
967
|
+
} finally {
|
|
968
|
+
db.close();
|
|
969
|
+
}
|
|
970
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
971
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
972
|
+
expect(code).toBe(0);
|
|
973
|
+
expect(stdout).toContain(deadId);
|
|
974
|
+
expect(stdout).toContain("DeadApp");
|
|
975
|
+
expect(stdout).toContain("--apply");
|
|
976
|
+
expect(stdout).toContain("nothing deleted");
|
|
977
|
+
|
|
978
|
+
// Count unchanged: the client is still there.
|
|
979
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
980
|
+
try {
|
|
981
|
+
const n = db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n;
|
|
982
|
+
expect(n).toBe(1);
|
|
983
|
+
} finally {
|
|
984
|
+
db2.close();
|
|
985
|
+
}
|
|
986
|
+
} finally {
|
|
987
|
+
tmp.cleanup();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test("--apply actually reaps + emits an audit line, dry-run is a no-op before it", async () => {
|
|
992
|
+
const tmp = makeTmp();
|
|
993
|
+
let deadId: string;
|
|
994
|
+
try {
|
|
995
|
+
const db = openHubDb(tmp.dbPath);
|
|
996
|
+
try {
|
|
997
|
+
deadId = oldClient(db, 60, "DeadApp");
|
|
998
|
+
} finally {
|
|
999
|
+
db.close();
|
|
1000
|
+
}
|
|
1001
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1002
|
+
|
|
1003
|
+
// Dry-run first: count before == count after.
|
|
1004
|
+
await captureOutput(() => auth(["reap-clients"], deps));
|
|
1005
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
1006
|
+
try {
|
|
1007
|
+
expect(db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(1);
|
|
1008
|
+
} finally {
|
|
1009
|
+
db2.close();
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// --apply deletes.
|
|
1013
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients", "--apply"], deps));
|
|
1014
|
+
expect(code).toBe(0);
|
|
1015
|
+
expect(stdout).toContain("Reaped 1 abandoned OAuth client");
|
|
1016
|
+
expect(stdout).toContain(`client reaped: client_id=${deadId}`);
|
|
1017
|
+
expect(stdout).toContain("client_name=DeadApp");
|
|
1018
|
+
|
|
1019
|
+
const db3 = openHubDb(tmp.dbPath);
|
|
1020
|
+
try {
|
|
1021
|
+
expect(db3.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(0);
|
|
1022
|
+
} finally {
|
|
1023
|
+
db3.close();
|
|
1024
|
+
}
|
|
1025
|
+
} finally {
|
|
1026
|
+
tmp.cleanup();
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
test("NEVER reaps a client with a live grant (--apply leaves it intact)", async () => {
|
|
1031
|
+
const tmp = makeTmp();
|
|
1032
|
+
let liveId: string;
|
|
1033
|
+
let userId: string;
|
|
1034
|
+
try {
|
|
1035
|
+
const db = openHubDb(tmp.dbPath);
|
|
1036
|
+
try {
|
|
1037
|
+
const user = await createUser(db, "owner", "pw");
|
|
1038
|
+
userId = user.id;
|
|
1039
|
+
liveId = oldClient(db, 60, "GrantedApp");
|
|
1040
|
+
recordGrant(db, userId, liveId, ["vault:work:read"]);
|
|
1041
|
+
} finally {
|
|
1042
|
+
db.close();
|
|
1043
|
+
}
|
|
1044
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1045
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients", "--apply"], deps));
|
|
1046
|
+
expect(code).toBe(0);
|
|
1047
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
1048
|
+
|
|
1049
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
1050
|
+
try {
|
|
1051
|
+
expect(
|
|
1052
|
+
db2.query("SELECT client_id FROM clients WHERE client_id = ?").get(liveId),
|
|
1053
|
+
).not.toBeNull();
|
|
1054
|
+
expect(findGrant(db2, userId, liveId)).not.toBeNull();
|
|
1055
|
+
} finally {
|
|
1056
|
+
db2.close();
|
|
1057
|
+
}
|
|
1058
|
+
} finally {
|
|
1059
|
+
tmp.cleanup();
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
test("NEVER reaps a freshly-registered client (inside the 30d floor)", async () => {
|
|
1064
|
+
const tmp = makeTmp();
|
|
1065
|
+
try {
|
|
1066
|
+
const db = openHubDb(tmp.dbPath);
|
|
1067
|
+
try {
|
|
1068
|
+
oldClient(db, 5); // 5 days old
|
|
1069
|
+
} finally {
|
|
1070
|
+
db.close();
|
|
1071
|
+
}
|
|
1072
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1073
|
+
const { code, stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
1074
|
+
expect(code).toBe(0);
|
|
1075
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
1076
|
+
} finally {
|
|
1077
|
+
tmp.cleanup();
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("--older-than tunes the age floor", async () => {
|
|
1082
|
+
const tmp = makeTmp();
|
|
1083
|
+
let id: string;
|
|
1084
|
+
try {
|
|
1085
|
+
const db = openHubDb(tmp.dbPath);
|
|
1086
|
+
try {
|
|
1087
|
+
id = oldClient(db, 15); // 15 days old
|
|
1088
|
+
} finally {
|
|
1089
|
+
db.close();
|
|
1090
|
+
}
|
|
1091
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1092
|
+
// Default 30d → not reapable.
|
|
1093
|
+
const def = await captureOutput(() => auth(["reap-clients"], deps));
|
|
1094
|
+
expect(def.stdout).toContain("No abandoned clients to reap.");
|
|
1095
|
+
// 10d floor → reapable.
|
|
1096
|
+
const tuned = await captureOutput(() => auth(["reap-clients", "--older-than", "10"], deps));
|
|
1097
|
+
expect(tuned.stdout).toContain(id);
|
|
1098
|
+
} finally {
|
|
1099
|
+
tmp.cleanup();
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("--json emits machine output; applied=false in dry-run, true with --apply", async () => {
|
|
1104
|
+
const tmp = makeTmp();
|
|
1105
|
+
let deadId: string;
|
|
1106
|
+
try {
|
|
1107
|
+
const db = openHubDb(tmp.dbPath);
|
|
1108
|
+
try {
|
|
1109
|
+
deadId = oldClient(db, 60, "JsonApp");
|
|
1110
|
+
} finally {
|
|
1111
|
+
db.close();
|
|
1112
|
+
}
|
|
1113
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1114
|
+
const dry = await captureOutput(() => auth(["reap-clients", "--json"], deps));
|
|
1115
|
+
expect(dry.code).toBe(0);
|
|
1116
|
+
const parsed = JSON.parse(dry.stdout) as {
|
|
1117
|
+
applied: boolean;
|
|
1118
|
+
count: number;
|
|
1119
|
+
clients: Array<{ clientId: string }>;
|
|
1120
|
+
};
|
|
1121
|
+
expect(parsed.applied).toBe(false);
|
|
1122
|
+
expect(parsed.count).toBe(1);
|
|
1123
|
+
expect(parsed.clients[0]?.clientId).toBe(deadId);
|
|
1124
|
+
// dry-run JSON deleted nothing.
|
|
1125
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
1126
|
+
try {
|
|
1127
|
+
expect(db2.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM clients").get()?.n).toBe(1);
|
|
1128
|
+
} finally {
|
|
1129
|
+
db2.close();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const wet = await captureOutput(() => auth(["reap-clients", "--json", "--apply"], deps));
|
|
1133
|
+
const wetParsed = JSON.parse(wet.stdout) as {
|
|
1134
|
+
applied: boolean;
|
|
1135
|
+
clients: Array<{ reaped?: boolean }>;
|
|
1136
|
+
};
|
|
1137
|
+
expect(wetParsed.applied).toBe(true);
|
|
1138
|
+
expect(wetParsed.clients[0]?.reaped).toBe(true);
|
|
1139
|
+
} finally {
|
|
1140
|
+
tmp.cleanup();
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
test("a client with only an in-flight auth_code is NEVER reaped", async () => {
|
|
1145
|
+
const tmp = makeTmp();
|
|
1146
|
+
let id: string;
|
|
1147
|
+
try {
|
|
1148
|
+
const db = openHubDb(tmp.dbPath);
|
|
1149
|
+
try {
|
|
1150
|
+
const user = await createUser(db, "owner", "pw");
|
|
1151
|
+
id = oldClient(db, 60);
|
|
1152
|
+
issueAuthCode(db, {
|
|
1153
|
+
clientId: id,
|
|
1154
|
+
userId: user.id,
|
|
1155
|
+
redirectUri: "https://app.example/cb",
|
|
1156
|
+
scopes: ["vault:work:read"],
|
|
1157
|
+
codeChallenge: "x".repeat(43),
|
|
1158
|
+
codeChallengeMethod: "S256",
|
|
1159
|
+
});
|
|
1160
|
+
} finally {
|
|
1161
|
+
db.close();
|
|
1162
|
+
}
|
|
1163
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1164
|
+
const { stdout } = await captureOutput(() => auth(["reap-clients"], deps));
|
|
1165
|
+
expect(stdout).toContain("No abandoned clients to reap.");
|
|
1166
|
+
} finally {
|
|
1167
|
+
tmp.cleanup();
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
test("rejects --older-than 0 / negative / non-integer", async () => {
|
|
1172
|
+
const tmp = makeTmp();
|
|
1173
|
+
try {
|
|
1174
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
1175
|
+
for (const bad of ["0", "-5", "abc"]) {
|
|
1176
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1177
|
+
auth(["reap-clients", "--older-than", bad], deps),
|
|
1178
|
+
);
|
|
1179
|
+
expect(code).toBe(1);
|
|
1180
|
+
expect(stderr).toContain("--older-than");
|
|
1181
|
+
}
|
|
1182
|
+
} finally {
|
|
1183
|
+
tmp.cleanup();
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
});
|
|
1187
|
+
|
|
921
1188
|
// closes #75 — operator-facing controls for the OAuth consent skip-list.
|
|
922
1189
|
describe("parachute auth list-grants / revoke-grant", () => {
|
|
923
1190
|
test("list-grants shows the seeding hint when no users exist", async () => {
|
|
@@ -8,9 +8,11 @@ import {
|
|
|
8
8
|
approveClient,
|
|
9
9
|
deleteClient,
|
|
10
10
|
expandRedirectUrisForHubOrigins,
|
|
11
|
+
findReapableClients,
|
|
11
12
|
getClient,
|
|
12
13
|
isValidRedirectUri,
|
|
13
14
|
listClientsByStatus,
|
|
15
|
+
reapClient,
|
|
14
16
|
registerClient,
|
|
15
17
|
requireRegisteredRedirectUri,
|
|
16
18
|
verifyClientSecret,
|
|
@@ -430,6 +432,213 @@ describe("deleteClient cascade (hub#640 RFC 7592 deregistration)", () => {
|
|
|
430
432
|
});
|
|
431
433
|
});
|
|
432
434
|
|
|
435
|
+
describe("findReapableClients / reapClient — the GC safety gate (hub#640)", () => {
|
|
436
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
437
|
+
// A fixed "now" so age math is deterministic. All planted clients register
|
|
438
|
+
// 60 days before this unless a test overrides.
|
|
439
|
+
const NOW = new Date("2026-06-27T00:00:00Z");
|
|
440
|
+
const now = () => NOW;
|
|
441
|
+
const OLD = new Date(NOW.getTime() - 60 * DAY_MS); // 60d old → past 30d floor
|
|
442
|
+
const oldNow = () => OLD;
|
|
443
|
+
|
|
444
|
+
/** Plant a `tokens` row with precise expiry/revocation for a client. */
|
|
445
|
+
function plantToken(
|
|
446
|
+
db: ReturnType<typeof openHubDb>,
|
|
447
|
+
opts: {
|
|
448
|
+
clientId: string;
|
|
449
|
+
userId: string;
|
|
450
|
+
jti: string;
|
|
451
|
+
expiresAt: string;
|
|
452
|
+
revokedAt?: string | null;
|
|
453
|
+
},
|
|
454
|
+
): void {
|
|
455
|
+
db.prepare(
|
|
456
|
+
`INSERT INTO tokens
|
|
457
|
+
(jti, user_id, client_id, scopes, refresh_token_hash, family_id,
|
|
458
|
+
expires_at, revoked_at, created_at, created_via, subject)
|
|
459
|
+
VALUES (?, ?, ?, '', NULL, NULL, ?, ?, ?, 'oauth_refresh', NULL)`,
|
|
460
|
+
).run(
|
|
461
|
+
opts.jti,
|
|
462
|
+
opts.userId,
|
|
463
|
+
opts.clientId,
|
|
464
|
+
opts.expiresAt,
|
|
465
|
+
opts.revokedAt ?? null,
|
|
466
|
+
OLD.toISOString(),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
test("a genuinely-dead old client IS reaped (no grants, only expired/revoked tokens, no live codes)", async () => {
|
|
471
|
+
const { db, cleanup } = makeDb();
|
|
472
|
+
try {
|
|
473
|
+
const user = await createUser(db, "owner", "pw");
|
|
474
|
+
const dead = registerClient(db, { redirectUris: ["https://dead.example/cb"], now: oldNow });
|
|
475
|
+
const id = dead.client.clientId;
|
|
476
|
+
// An expired token + a revoked token — both dead.
|
|
477
|
+
plantToken(db, {
|
|
478
|
+
clientId: id,
|
|
479
|
+
userId: user.id,
|
|
480
|
+
jti: "jti-expired",
|
|
481
|
+
expiresAt: new Date(NOW.getTime() - DAY_MS).toISOString(),
|
|
482
|
+
});
|
|
483
|
+
plantToken(db, {
|
|
484
|
+
clientId: id,
|
|
485
|
+
userId: user.id,
|
|
486
|
+
jti: "jti-revoked",
|
|
487
|
+
expiresAt: new Date(NOW.getTime() + DAY_MS).toISOString(), // unexpired...
|
|
488
|
+
revokedAt: new Date(NOW.getTime() - DAY_MS).toISOString(), // ...but revoked → dead
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const reapable = findReapableClients(db, { now });
|
|
492
|
+
expect(reapable.map((c) => c.clientId)).toEqual([id]);
|
|
493
|
+
expect(reapable[0]?.ageDays).toBe(60);
|
|
494
|
+
|
|
495
|
+
// reapClient removes the client AND its dead token rows.
|
|
496
|
+
expect(reapClient(db, id)).toBe(true);
|
|
497
|
+
expect(getClient(db, id)).toBeNull();
|
|
498
|
+
expect(db.query("SELECT jti FROM tokens WHERE client_id = ?").all(id)).toEqual([]);
|
|
499
|
+
} finally {
|
|
500
|
+
cleanup();
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("a client WITH a live grant is NEVER reaped, even when old", async () => {
|
|
505
|
+
const { db, cleanup } = makeDb();
|
|
506
|
+
try {
|
|
507
|
+
const user = await createUser(db, "owner", "pw");
|
|
508
|
+
const c = registerClient(db, { redirectUris: ["https://granted.example/cb"], now: oldNow });
|
|
509
|
+
recordGrant(db, user.id, c.client.clientId, ["vault:work:read"]);
|
|
510
|
+
const reapable = findReapableClients(db, { now });
|
|
511
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
|
|
512
|
+
} finally {
|
|
513
|
+
cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("a client with a live (unexpired, unrevoked) token is NEVER reaped, even when old", async () => {
|
|
518
|
+
const { db, cleanup } = makeDb();
|
|
519
|
+
try {
|
|
520
|
+
const user = await createUser(db, "owner", "pw");
|
|
521
|
+
const c = registerClient(db, { redirectUris: ["https://live.example/cb"], now: oldNow });
|
|
522
|
+
plantToken(db, {
|
|
523
|
+
clientId: c.client.clientId,
|
|
524
|
+
userId: user.id,
|
|
525
|
+
jti: "jti-live",
|
|
526
|
+
expiresAt: new Date(NOW.getTime() + 7 * DAY_MS).toISOString(),
|
|
527
|
+
revokedAt: null,
|
|
528
|
+
});
|
|
529
|
+
const reapable = findReapableClients(db, { now });
|
|
530
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
|
|
531
|
+
} finally {
|
|
532
|
+
cleanup();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a client with an unexpired auth_code is NEVER reaped (in-flight OAuth)", async () => {
|
|
537
|
+
const { db, cleanup } = makeDb();
|
|
538
|
+
try {
|
|
539
|
+
const user = await createUser(db, "owner", "pw");
|
|
540
|
+
const c = registerClient(db, { redirectUris: ["https://inflight.example/cb"], now: oldNow });
|
|
541
|
+
// issueAuthCode mints a code expiring AUTH_CODE_TTL after `now` → unexpired.
|
|
542
|
+
issueAuthCode(db, {
|
|
543
|
+
clientId: c.client.clientId,
|
|
544
|
+
userId: user.id,
|
|
545
|
+
redirectUri: "https://inflight.example/cb",
|
|
546
|
+
scopes: ["vault:work:read"],
|
|
547
|
+
codeChallenge: "x".repeat(43),
|
|
548
|
+
codeChallengeMethod: "S256",
|
|
549
|
+
now,
|
|
550
|
+
});
|
|
551
|
+
const reapable = findReapableClients(db, { now });
|
|
552
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(c.client.clientId);
|
|
553
|
+
} finally {
|
|
554
|
+
cleanup();
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a freshly-registered client is NEVER reaped, even with zero grants/tokens/codes", () => {
|
|
559
|
+
const { db, cleanup } = makeDb();
|
|
560
|
+
try {
|
|
561
|
+
// Registered 5 days ago — inside the default 30d floor.
|
|
562
|
+
const fresh = registerClient(db, {
|
|
563
|
+
redirectUris: ["https://fresh.example/cb"],
|
|
564
|
+
now: () => new Date(NOW.getTime() - 5 * DAY_MS),
|
|
565
|
+
});
|
|
566
|
+
const reapable = findReapableClients(db, { now });
|
|
567
|
+
expect(reapable.map((x) => x.clientId)).not.toContain(fresh.client.clientId);
|
|
568
|
+
} finally {
|
|
569
|
+
cleanup();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("an expired auth_code does NOT protect a client (only LIVE codes do)", async () => {
|
|
574
|
+
const { db, cleanup } = makeDb();
|
|
575
|
+
try {
|
|
576
|
+
const user = await createUser(db, "owner", "pw");
|
|
577
|
+
const c = registerClient(db, { redirectUris: ["https://stale.example/cb"], now: oldNow });
|
|
578
|
+
// Code issued 60d ago → long expired.
|
|
579
|
+
issueAuthCode(db, {
|
|
580
|
+
clientId: c.client.clientId,
|
|
581
|
+
userId: user.id,
|
|
582
|
+
redirectUri: "https://stale.example/cb",
|
|
583
|
+
scopes: ["vault:work:read"],
|
|
584
|
+
codeChallenge: "x".repeat(43),
|
|
585
|
+
codeChallengeMethod: "S256",
|
|
586
|
+
now: oldNow,
|
|
587
|
+
});
|
|
588
|
+
const reapable = findReapableClients(db, { now });
|
|
589
|
+
expect(reapable.map((x) => x.clientId)).toContain(c.client.clientId);
|
|
590
|
+
} finally {
|
|
591
|
+
cleanup();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("--older-than threshold is honored (a 20d-old client falls under a 10d floor but not 30d)", () => {
|
|
596
|
+
const { db, cleanup } = makeDb();
|
|
597
|
+
try {
|
|
598
|
+
const c = registerClient(db, {
|
|
599
|
+
redirectUris: ["https://midage.example/cb"],
|
|
600
|
+
now: () => new Date(NOW.getTime() - 20 * DAY_MS),
|
|
601
|
+
});
|
|
602
|
+
// Default 30d floor → not yet reapable.
|
|
603
|
+
expect(findReapableClients(db, { now }).map((x) => x.clientId)).not.toContain(
|
|
604
|
+
c.client.clientId,
|
|
605
|
+
);
|
|
606
|
+
// 10d floor → now reapable.
|
|
607
|
+
expect(
|
|
608
|
+
findReapableClients(db, { now, olderThanMs: 10 * DAY_MS }).map((x) => x.clientId),
|
|
609
|
+
).toContain(c.client.clientId);
|
|
610
|
+
} finally {
|
|
611
|
+
cleanup();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("reapClient on a still-protected client deletes nothing the gate excluded (callers pass only gated ids)", async () => {
|
|
616
|
+
// Belt-and-suspenders: reapClient itself doesn't re-check the gate (the
|
|
617
|
+
// caller is contractually responsible). This asserts the realistic flow —
|
|
618
|
+
// a live client is simply NOT in the findReapableClients output, so the
|
|
619
|
+
// apply loop never calls reapClient on it.
|
|
620
|
+
const { db, cleanup } = makeDb();
|
|
621
|
+
try {
|
|
622
|
+
const user = await createUser(db, "owner", "pw");
|
|
623
|
+
const live = registerClient(db, { redirectUris: ["https://keep.example/cb"], now: oldNow });
|
|
624
|
+
recordGrant(db, user.id, live.client.clientId, ["vault:work:read"]);
|
|
625
|
+
const dead = registerClient(db, { redirectUris: ["https://gone.example/cb"], now: oldNow });
|
|
626
|
+
|
|
627
|
+
const reapable = findReapableClients(db, { now });
|
|
628
|
+
const ids = reapable.map((x) => x.clientId);
|
|
629
|
+
expect(ids).toEqual([dead.client.clientId]);
|
|
630
|
+
for (const c of reapable) reapClient(db, c.clientId);
|
|
631
|
+
|
|
632
|
+
// Live client + its grant survive; only the dead one is gone.
|
|
633
|
+
expect(getClient(db, live.client.clientId)).not.toBeNull();
|
|
634
|
+
expect(findGrant(db, user.id, live.client.clientId)).not.toBeNull();
|
|
635
|
+
expect(getClient(db, dead.client.clientId)).toBeNull();
|
|
636
|
+
} finally {
|
|
637
|
+
cleanup();
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
433
642
|
describe("isValidRedirectUri", () => {
|
|
434
643
|
test("accepts http and https", () => {
|
|
435
644
|
expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
|
package/src/api-account-2fa.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { Database } from "bun:sqlite";
|
|
|
40
40
|
import { hash as argonHash } from "@node-rs/argon2";
|
|
41
41
|
import QRCode from "qrcode";
|
|
42
42
|
import { verifyCsrfToken } from "./csrf.ts";
|
|
43
|
-
import { changePasswordRateLimiter } from "./rate-limit.ts";
|
|
43
|
+
import { changePasswordRateLimiter, totpEnrollConfirmRateLimiter } from "./rate-limit.ts";
|
|
44
44
|
import { findActiveSession } from "./sessions.ts";
|
|
45
45
|
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
|
|
46
46
|
import {
|
|
@@ -220,6 +220,28 @@ async function handleConfirm(
|
|
|
220
220
|
if (isTotpEnrolled(deps.db, user.id)) {
|
|
221
221
|
return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
|
|
222
222
|
}
|
|
223
|
+
// Bound a hijacked session grinding the in-flight (client-held) secret. Keyed
|
|
224
|
+
// by user.id, lenient (10/15min) so honest enroll mistypes aren't punished —
|
|
225
|
+
// defense-in-depth (#712). Fires AFTER the format + already-enrolled guards so
|
|
226
|
+
// junk/no-op POSTs don't burn the legit enroller's budget, and BEFORE the
|
|
227
|
+
// code verify so the grind window is actually bounded. A SUCCESSFUL confirm
|
|
228
|
+
// also consumes one slot (checkAndRecord counts every attempt) — harmless,
|
|
229
|
+
// since an enrolled account 409s on any further confirm anyway.
|
|
230
|
+
const confirmLimited = totpEnrollConfirmRateLimiter.checkAndRecord(
|
|
231
|
+
user.id,
|
|
232
|
+
deps.now ? deps.now() : new Date(),
|
|
233
|
+
);
|
|
234
|
+
if (!confirmLimited.allowed) {
|
|
235
|
+
const retryAfter = confirmLimited.retryAfterSeconds ?? 1;
|
|
236
|
+
return json(
|
|
237
|
+
429,
|
|
238
|
+
{
|
|
239
|
+
error: "too_many_attempts",
|
|
240
|
+
error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
|
|
241
|
+
},
|
|
242
|
+
{ "retry-after": String(retryAfter) },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
223
245
|
if (!verifyTotpCode(secret, code)) {
|
|
224
246
|
return jsonError(
|
|
225
247
|
400,
|
package/src/clients.ts
CHANGED
|
@@ -238,6 +238,135 @@ export function deleteClient(db: Database, clientId: string): boolean {
|
|
|
238
238
|
})();
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Default reaper age threshold: 30 days. A client registered more recently
|
|
243
|
+
* than this is NEVER reaped — it may be mid-first-OAuth-flow (registered →
|
|
244
|
+
* user is on the consent screen → no grant/token/code written yet). The
|
|
245
|
+
* threshold buys generous headroom over the worst-case human consent latency.
|
|
246
|
+
*/
|
|
247
|
+
export const DEFAULT_REAP_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
248
|
+
|
|
249
|
+
/** A client the reaper has proven dead. Carries enough to display a dry-run row. */
|
|
250
|
+
export interface ReapableClient {
|
|
251
|
+
clientId: string;
|
|
252
|
+
clientName: string | null;
|
|
253
|
+
registeredAt: string;
|
|
254
|
+
status: ClientStatus;
|
|
255
|
+
/** Whole days since registration, floored. For the dry-run/`--json` display. */
|
|
256
|
+
ageDays: number;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface FindReapableOpts {
|
|
260
|
+
/** Reap only clients older than this many ms. Defaults to 30 days. */
|
|
261
|
+
olderThanMs?: number;
|
|
262
|
+
/** Injectable clock — all age + liveness comparisons use this. */
|
|
263
|
+
now?: () => Date;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Find OAuth clients that are PROVABLY DEAD and safe to garbage-collect.
|
|
268
|
+
*
|
|
269
|
+
* THE SAFETY GATE (maximally conservative — see hub#640). DCR churn (Notes /
|
|
270
|
+
* Claude mint a fresh `client_id` per reconnect) accumulates dead `clients`
|
|
271
|
+
* rows; this reaps them. But a reaper that deletes a LIVE client breaks a
|
|
272
|
+
* user's active connection, and we've been bitten by over-eager pruning
|
|
273
|
+
* before (a derived-key divergence wrongly pruned still-wanted grants). So a
|
|
274
|
+
* client is reapable IFF **ALL** of:
|
|
275
|
+
*
|
|
276
|
+
* 1. ZERO rows in `grants` — no standing consent. A grant means the user
|
|
277
|
+
* approved this client; never touch it.
|
|
278
|
+
* 2. ZERO live tokens — no row in `tokens` with `expires_at > now AND
|
|
279
|
+
* revoked_at IS NULL`. A live token means an active session.
|
|
280
|
+
* 3. ZERO live auth_codes — no row in `auth_codes` with `expires_at > now`.
|
|
281
|
+
* A live code means an OAuth exchange is in flight RIGHT NOW.
|
|
282
|
+
* 4. `registered_at` older than `olderThanMs` (default 30d) — a freshly-
|
|
283
|
+
* registered client may be mid-first-flow before any of the above rows
|
|
284
|
+
* land.
|
|
285
|
+
*
|
|
286
|
+
* This reaps abandoned-never-consented `pending` DCR registrations and fully-
|
|
287
|
+
* dead (revoked/expired) clients, and NEVER a client with a live grant, live
|
|
288
|
+
* token, or in-flight code. Pure read — touches nothing. `reapClient` does the
|
|
289
|
+
* deletion.
|
|
290
|
+
*
|
|
291
|
+
* Implemented as a single `NOT EXISTS` SELECT so the gate is evaluated
|
|
292
|
+
* atomically per row against one consistent DB snapshot (no read-modify-write
|
|
293
|
+
* window where a token could land between the check and a later delete — the
|
|
294
|
+
* delete re-derives nothing; callers re-run the gate, see below).
|
|
295
|
+
*/
|
|
296
|
+
export function findReapableClients(db: Database, opts: FindReapableOpts = {}): ReapableClient[] {
|
|
297
|
+
const now = opts.now?.() ?? new Date();
|
|
298
|
+
const olderThanMs = opts.olderThanMs ?? DEFAULT_REAP_AGE_MS;
|
|
299
|
+
const nowIso = now.toISOString();
|
|
300
|
+
// Registered strictly before this cutoff to qualify on age (condition 4).
|
|
301
|
+
const cutoffIso = new Date(now.getTime() - olderThanMs).toISOString();
|
|
302
|
+
|
|
303
|
+
const rows = db
|
|
304
|
+
.query<Row, [string, string, string]>(
|
|
305
|
+
`SELECT c.* FROM clients c
|
|
306
|
+
WHERE c.registered_at < ?1
|
|
307
|
+
AND NOT EXISTS (SELECT 1 FROM grants g WHERE g.client_id = c.client_id)
|
|
308
|
+
AND NOT EXISTS (
|
|
309
|
+
SELECT 1 FROM tokens t
|
|
310
|
+
WHERE t.client_id = c.client_id
|
|
311
|
+
AND t.revoked_at IS NULL
|
|
312
|
+
AND t.expires_at > ?2
|
|
313
|
+
)
|
|
314
|
+
AND NOT EXISTS (
|
|
315
|
+
SELECT 1 FROM auth_codes a
|
|
316
|
+
WHERE a.client_id = c.client_id
|
|
317
|
+
AND a.expires_at > ?3
|
|
318
|
+
)
|
|
319
|
+
ORDER BY c.registered_at`,
|
|
320
|
+
)
|
|
321
|
+
.all(cutoffIso, nowIso, nowIso);
|
|
322
|
+
|
|
323
|
+
return rows.map((r) => {
|
|
324
|
+
const client = rowToClient(r);
|
|
325
|
+
const ageMs = now.getTime() - new Date(client.registeredAt).getTime();
|
|
326
|
+
return {
|
|
327
|
+
clientId: client.clientId,
|
|
328
|
+
clientName: client.clientName,
|
|
329
|
+
registeredAt: client.registeredAt,
|
|
330
|
+
status: client.status,
|
|
331
|
+
ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Garbage-collect a single PROVABLY-DEAD client. Wraps the `deleteClient`
|
|
338
|
+
* cascade (grants + auth_codes + client row) AND a `DELETE FROM tokens` for
|
|
339
|
+
* the same client_id, all in ONE transaction.
|
|
340
|
+
*
|
|
341
|
+
* Why also delete tokens here when `deleteClient` deliberately leaves them?
|
|
342
|
+
* `deleteClient` is the general deregistration path — it can run against a
|
|
343
|
+
* client that still has LIVE tokens (an operator force-removing an app mid-
|
|
344
|
+
* session), so it leaves the `tokens` rows to expire on their own (they carry
|
|
345
|
+
* no FK to `clients`). The reaper, by contrast, only ever runs on a client the
|
|
346
|
+
* gate has PROVEN has no live tokens — every remaining `tokens` row for it is
|
|
347
|
+
* already expired or revoked (dead). Deleting them completes the GC instead of
|
|
348
|
+
* leaving dead registry rows behind forever. Callers MUST pass only client_ids
|
|
349
|
+
* returned by `findReapableClients` (the safety gate); see the CLI's reap loop.
|
|
350
|
+
*
|
|
351
|
+
* TOCTOU note: `reapClient` does NOT re-check the gate — it trusts the caller's
|
|
352
|
+
* list. There is a theoretical window where a grant/token lands between the
|
|
353
|
+
* `findReapableClients` snapshot and this delete, reaping a client that just
|
|
354
|
+
* went live. On the single-operator hub this is cosmetically impossible: the
|
|
355
|
+
* OAuth flow that would create a grant runs synchronously on the same SQLite
|
|
356
|
+
* connection and never races a CLI invocation. Re-running `findReapableClients`
|
|
357
|
+
* immediately before each call would close the window at the cost of N extra
|
|
358
|
+
* round-trips; not worth it under the current trust model.
|
|
359
|
+
*/
|
|
360
|
+
export function reapClient(db: Database, clientId: string): boolean {
|
|
361
|
+
return db.transaction(() => {
|
|
362
|
+
// Dead token rows first — the gate proved none are live; this completes
|
|
363
|
+
// the GC (deleteClient leaves tokens alone for the general delete path).
|
|
364
|
+
db.prepare("DELETE FROM tokens WHERE client_id = ?").run(clientId);
|
|
365
|
+
// The cascade: auth_codes + grants (both dead per the gate) + the client.
|
|
366
|
+
return deleteClient(db, clientId);
|
|
367
|
+
})();
|
|
368
|
+
}
|
|
369
|
+
|
|
241
370
|
/**
|
|
242
371
|
* Returns the registered redirect URI matching `candidate` exactly, or throws.
|
|
243
372
|
* RFC 8252 + 6749 require exact-match for redirect URIs (no wildcards, no
|
package/src/commands/auth.ts
CHANGED
|
@@ -25,7 +25,16 @@
|
|
|
25
25
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { createInterface } from "node:readline/promises";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_REAP_AGE_MS,
|
|
30
|
+
type ReapableClient,
|
|
31
|
+
approveClient,
|
|
32
|
+
deleteClient,
|
|
33
|
+
findReapableClients,
|
|
34
|
+
getClient,
|
|
35
|
+
listClientsByStatus,
|
|
36
|
+
reapClient,
|
|
37
|
+
} from "../clients.ts";
|
|
29
38
|
import { CONFIG_DIR } from "../config.ts";
|
|
30
39
|
import { readExposeState } from "../expose-state.ts";
|
|
31
40
|
import { listGrantsForUser, revokeGrant } from "../grants.ts";
|
|
@@ -97,6 +106,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
|
97
106
|
"pending-clients",
|
|
98
107
|
"approve-client",
|
|
99
108
|
"revoke-client",
|
|
109
|
+
"reap-clients",
|
|
100
110
|
"list-grants",
|
|
101
111
|
"revoke-grant",
|
|
102
112
|
]);
|
|
@@ -139,6 +149,12 @@ Usage:
|
|
|
139
149
|
parachute auth revoke-client <id> Deregister (delete) an OAuth client,
|
|
140
150
|
cascading its grants + auth codes
|
|
141
151
|
(RFC 7592 deregistration)
|
|
152
|
+
parachute auth reap-clients [--older-than <days>] [--apply] [--json]
|
|
153
|
+
Garbage-collect abandoned/dead OAuth
|
|
154
|
+
clients (DCR reconnect churn). Dry-run
|
|
155
|
+
by default — lists what WOULD be reaped
|
|
156
|
+
and deletes nothing; pass --apply to
|
|
157
|
+
actually delete.
|
|
142
158
|
parachute auth list-grants [--username <name>]
|
|
143
159
|
Show OAuth scope grants on record
|
|
144
160
|
parachute auth revoke-grant <client_id> [--username <name>]
|
|
@@ -242,6 +258,19 @@ and cannot OAuth until you run \`parachute auth approve-client <id>\`.
|
|
|
242
258
|
First-party install flows that present \`Authorization: Bearer
|
|
243
259
|
<operator-token>\` with \`hub:admin\` scope land as 'approved' immediately.
|
|
244
260
|
|
|
261
|
+
reap-clients garbage-collects abandoned/dead OAuth clients (closes #640).
|
|
262
|
+
DCR churn — Notes/Claude mint a FRESH client_id per reconnect — accumulates
|
|
263
|
+
dead \`clients\` rows in the operator's hub.db. reap-clients deletes only the
|
|
264
|
+
PROVABLY-DEAD ones: a client is reapable iff it has ZERO grants (no standing
|
|
265
|
+
consent), ZERO live tokens (none unexpired+unrevoked), ZERO in-flight auth
|
|
266
|
+
codes, AND was registered more than --older-than days ago (default 30). A
|
|
267
|
+
client with a live grant, a live token, or an in-flight code is NEVER touched.
|
|
268
|
+
|
|
269
|
+
Dry-run by DEFAULT: prints the clients that WOULD be reaped and deletes
|
|
270
|
+
nothing — review before deleting. Pass \`--apply\` to actually delete them
|
|
271
|
+
(grants + auth codes + dead tokens cascade). \`--older-than <days>\` tunes the
|
|
272
|
+
age floor; \`--json\` emits machine-readable output.
|
|
273
|
+
|
|
245
274
|
list-grants + revoke-grant manage the OAuth consent skip-list (closes
|
|
246
275
|
#75). When you approve a scope-set on the consent screen, the hub
|
|
247
276
|
records it so re-running the same flow goes straight to the auth-code
|
|
@@ -661,6 +690,179 @@ function runRevokeClient(args: readonly string[], deps: AuthDeps): number {
|
|
|
661
690
|
}
|
|
662
691
|
}
|
|
663
692
|
|
|
693
|
+
interface ReapClientsFlags {
|
|
694
|
+
/** Age floor in days. Defaults to DEFAULT_REAP_AGE_MS / day. */
|
|
695
|
+
olderThanDays?: number;
|
|
696
|
+
/** --apply: actually delete. Without it, dry-run (default). */
|
|
697
|
+
apply: boolean;
|
|
698
|
+
/** --json: machine-readable output. */
|
|
699
|
+
json: boolean;
|
|
700
|
+
error?: string;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function parseReapClientsFlags(args: readonly string[]): ReapClientsFlags {
|
|
704
|
+
let olderThanDays: number | undefined;
|
|
705
|
+
let apply = false;
|
|
706
|
+
let json = false;
|
|
707
|
+
const parseDays = (raw: string | undefined): number | undefined | "error" => {
|
|
708
|
+
if (!raw) return "error";
|
|
709
|
+
// Whole, positive day count. Reject 0 / negatives / non-integers — an
|
|
710
|
+
// --older-than 0 would reap freshly-registered clients (the exact thing
|
|
711
|
+
// condition 4 of the gate exists to prevent).
|
|
712
|
+
if (!/^\d+$/.test(raw)) return "error";
|
|
713
|
+
const n = Number.parseInt(raw, 10);
|
|
714
|
+
if (!Number.isFinite(n) || n <= 0) return "error";
|
|
715
|
+
return n;
|
|
716
|
+
};
|
|
717
|
+
for (let i = 0; i < args.length; i++) {
|
|
718
|
+
const a = args[i];
|
|
719
|
+
if (a === "--older-than") {
|
|
720
|
+
const parsed = parseDays(args[++i]);
|
|
721
|
+
if (parsed === "error")
|
|
722
|
+
return { apply, json, error: "--older-than requires a positive integer (days)" };
|
|
723
|
+
olderThanDays = parsed;
|
|
724
|
+
} else if (a?.startsWith("--older-than=")) {
|
|
725
|
+
const parsed = parseDays(a.slice("--older-than=".length));
|
|
726
|
+
if (parsed === "error")
|
|
727
|
+
return { apply, json, error: "--older-than requires a positive integer (days)" };
|
|
728
|
+
olderThanDays = parsed;
|
|
729
|
+
} else if (a === "--apply") {
|
|
730
|
+
apply = true;
|
|
731
|
+
} else if (a === "--json") {
|
|
732
|
+
json = true;
|
|
733
|
+
} else {
|
|
734
|
+
return { apply, json, error: `unknown flag "${a}"` };
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return olderThanDays !== undefined ? { olderThanDays, apply, json } : { apply, json };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* `parachute auth reap-clients` — garbage-collect abandoned/dead OAuth clients
|
|
742
|
+
* (closes hub#640). DCR churn (Notes/Claude mint a fresh client_id per
|
|
743
|
+
* reconnect) accumulates dead `clients` rows; this reaps the PROVABLY-DEAD
|
|
744
|
+
* ones via the conservative `findReapableClients` gate (zero grants, zero live
|
|
745
|
+
* tokens, zero in-flight auth_codes, older than the age floor).
|
|
746
|
+
*
|
|
747
|
+
* DRY-RUN BY DEFAULT — the load-bearing safety choice. A reaper that deletes a
|
|
748
|
+
* live client breaks a user's active connection, so the default run only
|
|
749
|
+
* REPORTS what it would delete and touches nothing; `--apply` performs the
|
|
750
|
+
* deletion. The same `findReapableClients` snapshot drives both the dry-run
|
|
751
|
+
* listing and the --apply loop, so what's printed is exactly what gets reaped.
|
|
752
|
+
*/
|
|
753
|
+
function runReapClients(args: readonly string[], deps: AuthDeps): number {
|
|
754
|
+
const flags = parseReapClientsFlags(args);
|
|
755
|
+
if (flags.error) {
|
|
756
|
+
console.error(`parachute auth reap-clients: ${flags.error}`);
|
|
757
|
+
console.error("usage: parachute auth reap-clients [--older-than <days>] [--apply] [--json]");
|
|
758
|
+
return 1;
|
|
759
|
+
}
|
|
760
|
+
const olderThanMs =
|
|
761
|
+
flags.olderThanDays !== undefined
|
|
762
|
+
? flags.olderThanDays * 24 * 60 * 60 * 1000
|
|
763
|
+
: DEFAULT_REAP_AGE_MS;
|
|
764
|
+
const olderThanDays = olderThanMs / (24 * 60 * 60 * 1000);
|
|
765
|
+
|
|
766
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
767
|
+
try {
|
|
768
|
+
const reapable = findReapableClients(db, { olderThanMs });
|
|
769
|
+
|
|
770
|
+
if (flags.json) {
|
|
771
|
+
// Machine-readable: the candidate set + whether we actually deleted.
|
|
772
|
+
// In --apply mode each row carries `reaped: true` after deletion. Audit
|
|
773
|
+
// lines route to stderr so stdout stays pure JSON for piping into `jq`.
|
|
774
|
+
const deleted = flags.apply ? applyReap(db, reapable, console.error) : [];
|
|
775
|
+
console.log(
|
|
776
|
+
JSON.stringify(
|
|
777
|
+
{
|
|
778
|
+
applied: flags.apply,
|
|
779
|
+
olderThanDays,
|
|
780
|
+
count: reapable.length,
|
|
781
|
+
clients: reapable.map((c) => ({
|
|
782
|
+
clientId: c.clientId,
|
|
783
|
+
clientName: c.clientName,
|
|
784
|
+
registeredAt: c.registeredAt,
|
|
785
|
+
status: c.status,
|
|
786
|
+
ageDays: c.ageDays,
|
|
787
|
+
...(flags.apply ? { reaped: deleted.includes(c.clientId) } : {}),
|
|
788
|
+
})),
|
|
789
|
+
},
|
|
790
|
+
null,
|
|
791
|
+
2,
|
|
792
|
+
),
|
|
793
|
+
);
|
|
794
|
+
return 0;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (reapable.length === 0) {
|
|
798
|
+
console.log("No abandoned clients to reap.");
|
|
799
|
+
return 0;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
printReapTable(reapable);
|
|
803
|
+
|
|
804
|
+
if (!flags.apply) {
|
|
805
|
+
const plural = reapable.length === 1 ? "client" : "clients";
|
|
806
|
+
console.log("");
|
|
807
|
+
console.log(
|
|
808
|
+
`Dry run — nothing deleted. Run with --apply to delete ${reapable.length} ${plural}.`,
|
|
809
|
+
);
|
|
810
|
+
return 0;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const deleted = applyReap(db, reapable);
|
|
814
|
+
console.log("");
|
|
815
|
+
const plural = deleted.length === 1 ? "client" : "clients";
|
|
816
|
+
console.log(
|
|
817
|
+
`Reaped ${deleted.length} abandoned OAuth ${plural} (grants + auth codes + dead tokens cascaded).`,
|
|
818
|
+
);
|
|
819
|
+
return 0;
|
|
820
|
+
} finally {
|
|
821
|
+
db.close();
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Print the dry-run / apply table of reapable clients. */
|
|
826
|
+
function printReapTable(reapable: readonly ReapableClient[]): void {
|
|
827
|
+
console.log(
|
|
828
|
+
"CLIENT_ID NAME STATUS AGE_DAYS REGISTERED",
|
|
829
|
+
);
|
|
830
|
+
for (const c of reapable) {
|
|
831
|
+
const id = c.clientId.padEnd(36).slice(0, 36);
|
|
832
|
+
const name = (c.clientName ?? "").padEnd(20).slice(0, 20);
|
|
833
|
+
const status = c.status.padEnd(8).slice(0, 8);
|
|
834
|
+
const age = String(c.ageDays).padStart(8);
|
|
835
|
+
console.log(`${id} ${name} ${status} ${age} ${c.registeredAt}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Delete each reapable client via `reapClient`, emitting one audit line per
|
|
841
|
+
* deletion (`client reaped:` — greppable in hub.log alongside `client
|
|
842
|
+
* deleted:` from the manual revoke path). Returns the client_ids actually
|
|
843
|
+
* removed. Callers pass ONLY the `findReapableClients` output, so every id
|
|
844
|
+
* here has already cleared the safety gate.
|
|
845
|
+
*
|
|
846
|
+
* `audit` is the sink for the per-deletion lines — `console.log` for the human
|
|
847
|
+
* table path, `console.error` for `--json` (keeps stdout pure JSON for `jq`).
|
|
848
|
+
*/
|
|
849
|
+
function applyReap(
|
|
850
|
+
db: ReturnType<typeof openHubDb>,
|
|
851
|
+
reapable: readonly ReapableClient[],
|
|
852
|
+
audit: (line: string) => void = console.log,
|
|
853
|
+
): string[] {
|
|
854
|
+
const deleted: string[] = [];
|
|
855
|
+
for (const c of reapable) {
|
|
856
|
+
if (reapClient(db, c.clientId)) {
|
|
857
|
+
deleted.push(c.clientId);
|
|
858
|
+
audit(
|
|
859
|
+
`client reaped: client_id=${c.clientId} client_name=${c.clientName ?? ""} age_days=${c.ageDays} status=${c.status}`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return deleted;
|
|
864
|
+
}
|
|
865
|
+
|
|
664
866
|
interface UsernameFlag {
|
|
665
867
|
username?: string;
|
|
666
868
|
rest: string[];
|
|
@@ -1456,6 +1658,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1456
1658
|
return 1;
|
|
1457
1659
|
}
|
|
1458
1660
|
}
|
|
1661
|
+
if (sub === "reap-clients") {
|
|
1662
|
+
try {
|
|
1663
|
+
return runReapClients(args.slice(1), normalized);
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1666
|
+
console.error(`parachute auth reap-clients: ${msg}`);
|
|
1667
|
+
return 1;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1459
1670
|
if (sub === "list-grants") {
|
|
1460
1671
|
try {
|
|
1461
1672
|
return runListGrants(args.slice(1), normalized);
|
package/src/hub-db.ts
CHANGED
|
@@ -558,6 +558,20 @@ const MIGRATIONS: readonly Migration[] = [
|
|
|
558
558
|
);
|
|
559
559
|
`,
|
|
560
560
|
},
|
|
561
|
+
{
|
|
562
|
+
version: 16,
|
|
563
|
+
sql: `
|
|
564
|
+
-- Index tokens by client_id for the OAuth client GC reaper (#640). The
|
|
565
|
+
-- reaper's gate runs a correlated NOT EXISTS (SELECT 1 FROM tokens WHERE
|
|
566
|
+
-- client_id = ? AND ...) per candidate client; tokens previously had no
|
|
567
|
+
-- client_id index (only user_id / refresh_token_hash / family_id /
|
|
568
|
+
-- revoked_at / subject), so each check was a full tokens-table walk. Under
|
|
569
|
+
-- the DCR reconnect churn this GC targets — thousands of dead token rows
|
|
570
|
+
-- accumulating before a sweep — that is O(total tokens) per client. This
|
|
571
|
+
-- makes it O(tokens for that client). IF NOT EXISTS so re-opens are inert.
|
|
572
|
+
CREATE INDEX IF NOT EXISTS tokens_client ON tokens (client_id);
|
|
573
|
+
`,
|
|
574
|
+
},
|
|
561
575
|
];
|
|
562
576
|
|
|
563
577
|
export function openHubDb(path: string = hubDbPath()): Database {
|
package/src/rate-limit.ts
CHANGED
|
@@ -87,6 +87,21 @@ export const CHANGE_PASSWORD_WINDOW_MS = 5 * 60 * 1000;
|
|
|
87
87
|
* cookie attacker shouldn't get a 5-shot grind window.
|
|
88
88
|
*/
|
|
89
89
|
export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
|
|
90
|
+
/**
|
|
91
|
+
* `/api/account/2fa/confirm` (TOTP enrollment seal) window: 15 minutes. This is
|
|
92
|
+
* the SELF-only, already-session-authenticated enrollment step — the operator is
|
|
93
|
+
* typing the first live code off their own authenticator while it drifts into
|
|
94
|
+
* sync, so legitimate mistypes are common and must not be punished. The threat
|
|
95
|
+
* is only a hijacked session grinding the (client-held, not-yet-persisted)
|
|
96
|
+
* in-flight secret, which the 10^6 code space + replay cache already make
|
|
97
|
+
* effectively non-exploitable — so this is defense-in-depth, deliberately MORE
|
|
98
|
+
* generous than the 3/5-min change-password bucket. NOT the `/login/2fa` bucket:
|
|
99
|
+
* that one is the strict, pre-auth brute-force door (5/15-min); enrollment is a
|
|
100
|
+
* different, lower-risk surface and gets its own lenient bucket.
|
|
101
|
+
*/
|
|
102
|
+
export const TOTP_ENROLL_CONFIRM_WINDOW_MS = 15 * 60 * 1000;
|
|
103
|
+
/** `/api/account/2fa/confirm` attempts allowed per window. 11th is denied. */
|
|
104
|
+
export const TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS = 10;
|
|
90
105
|
/**
|
|
91
106
|
* `/login/2fa` window length: 15 minutes — same as `/login`. The second-
|
|
92
107
|
* factor step (hub#473) sits behind a verified password + a short-lived
|
|
@@ -289,6 +304,18 @@ export const changePasswordRateLimiter = new RateLimiter(
|
|
|
289
304
|
*/
|
|
290
305
|
export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
|
|
291
306
|
|
|
307
|
+
/**
|
|
308
|
+
* `/api/account/2fa/confirm` enrollment-seal bucket. Lenient (10 / 15 min),
|
|
309
|
+
* keyed by `user.id` (the session already establishes identity). Separate from
|
|
310
|
+
* `totpRateLimiter` so an enrollment mistype and a `/login/2fa` failure never
|
|
311
|
+
* share a window — different surfaces, different threat models (see the const
|
|
312
|
+
* docs above).
|
|
313
|
+
*/
|
|
314
|
+
export const totpEnrollConfirmRateLimiter = new RateLimiter(
|
|
315
|
+
TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS,
|
|
316
|
+
TOTP_ENROLL_CONFIRM_WINDOW_MS,
|
|
317
|
+
);
|
|
318
|
+
|
|
292
319
|
/**
|
|
293
320
|
* Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
|
|
294
321
|
* IP ONLY. Shared by all interactive auth doors (`/login`, the
|
|
@@ -342,6 +369,7 @@ export function __resetForTests(): void {
|
|
|
342
369
|
loginRateLimiter.reset();
|
|
343
370
|
changePasswordRateLimiter.reset();
|
|
344
371
|
totpRateLimiter.reset();
|
|
372
|
+
totpEnrollConfirmRateLimiter.reset();
|
|
345
373
|
vaultTokenMintRateLimiter.reset();
|
|
346
374
|
signupRateLimiter.reset();
|
|
347
375
|
authIpCeilingRateLimiter.reset();
|