@openparachute/hub 0.7.4-rc.8 → 0.7.4
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-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +276 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +25 -5
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
package/package.json
CHANGED
|
@@ -172,6 +172,134 @@ describe("requireScope", () => {
|
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
describe("requireScope multi-origin issuer set (hub#516 parity)", () => {
|
|
176
|
+
// The hub answers on several legitimate origins after `parachute hub
|
|
177
|
+
// set-origin` / `expose` (loopback ∪ tunnel ∪ public). A credential minted
|
|
178
|
+
// under a still-valid prior origin must keep validating at admin-auth even
|
|
179
|
+
// when the per-request canonical issuer is now a different member of the set.
|
|
180
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
181
|
+
const TUNNEL = "https://brain.gitcoin.co";
|
|
182
|
+
const FOREIGN = "https://evil.example.com";
|
|
183
|
+
|
|
184
|
+
test("accepts a token whose iss is in the set but ≠ the per-request canonical", async () => {
|
|
185
|
+
const h = makeHarness();
|
|
186
|
+
try {
|
|
187
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
188
|
+
try {
|
|
189
|
+
rotateSigningKey(db);
|
|
190
|
+
// Token minted under the TUNNEL origin (e.g. before `set-origin`)…
|
|
191
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
|
|
192
|
+
// …presented to admin-auth where the canonical per-request issuer is now
|
|
193
|
+
// LOOPBACK, but the bound-origin set still includes TUNNEL.
|
|
194
|
+
const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
|
|
195
|
+
LOOPBACK,
|
|
196
|
+
TUNNEL,
|
|
197
|
+
]);
|
|
198
|
+
expect(ctx.sub).toBe("user-test");
|
|
199
|
+
expect(ctx.scopes).toContain("parachute:host:admin");
|
|
200
|
+
} finally {
|
|
201
|
+
db.close();
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
h.cleanup();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("rejects 401 when iss is OUTSIDE the set", async () => {
|
|
209
|
+
const h = makeHarness();
|
|
210
|
+
try {
|
|
211
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
212
|
+
try {
|
|
213
|
+
rotateSigningKey(db);
|
|
214
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: FOREIGN });
|
|
215
|
+
let caught: AdminAuthError | null = null;
|
|
216
|
+
try {
|
|
217
|
+
await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
|
|
218
|
+
LOOPBACK,
|
|
219
|
+
TUNNEL,
|
|
220
|
+
]);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
caught = err as AdminAuthError;
|
|
223
|
+
}
|
|
224
|
+
expect(caught?.status).toBe(401);
|
|
225
|
+
} finally {
|
|
226
|
+
db.close();
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
h.cleanup();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("rejects 401 on signature failure regardless of the set (signature-first)", async () => {
|
|
234
|
+
const h = makeHarness();
|
|
235
|
+
try {
|
|
236
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
237
|
+
try {
|
|
238
|
+
rotateSigningKey(db);
|
|
239
|
+
// A hub-signed token, then rotate keys + retire so the original kid no
|
|
240
|
+
// longer verifies — the JWKS signature gate must fire before any iss
|
|
241
|
+
// membership check, so an in-set iss can't rescue an unverifiable token.
|
|
242
|
+
let caught: AdminAuthError | null = null;
|
|
243
|
+
try {
|
|
244
|
+
await requireScope(db, reqWithAuth("Bearer not-a-real-jwt"), "parachute:host:admin", [
|
|
245
|
+
LOOPBACK,
|
|
246
|
+
TUNNEL,
|
|
247
|
+
]);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
caught = err as AdminAuthError;
|
|
250
|
+
}
|
|
251
|
+
expect(caught?.status).toBe(401);
|
|
252
|
+
} finally {
|
|
253
|
+
db.close();
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
h.cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("single-string back-compat: in-set iss as a lone string still validates", async () => {
|
|
261
|
+
const h = makeHarness();
|
|
262
|
+
try {
|
|
263
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
264
|
+
try {
|
|
265
|
+
rotateSigningKey(db);
|
|
266
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: ISSUER });
|
|
267
|
+
// A single-element set behaves exactly like the prior single-string form.
|
|
268
|
+
const ctx = await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [
|
|
269
|
+
ISSUER,
|
|
270
|
+
]);
|
|
271
|
+
expect(ctx.sub).toBe("user-test");
|
|
272
|
+
} finally {
|
|
273
|
+
db.close();
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
h.cleanup();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("single-element set rejects a non-matching iss (no accidental widening)", async () => {
|
|
281
|
+
const h = makeHarness();
|
|
282
|
+
try {
|
|
283
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
284
|
+
try {
|
|
285
|
+
rotateSigningKey(db);
|
|
286
|
+
const token = await mintToken(db, ["parachute:host:admin"], { issuer: TUNNEL });
|
|
287
|
+
let caught: AdminAuthError | null = null;
|
|
288
|
+
try {
|
|
289
|
+
await requireScope(db, reqWithAuth(`Bearer ${token}`), "parachute:host:admin", [ISSUER]);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
caught = err as AdminAuthError;
|
|
292
|
+
}
|
|
293
|
+
expect(caught?.status).toBe(401);
|
|
294
|
+
} finally {
|
|
295
|
+
db.close();
|
|
296
|
+
}
|
|
297
|
+
} finally {
|
|
298
|
+
h.cleanup();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
175
303
|
describe("adminAuthErrorResponse", () => {
|
|
176
304
|
test("403 → insufficient_scope with WWW-Authenticate", async () => {
|
|
177
305
|
const res = adminAuthErrorResponse(new AdminAuthError(403, "needs admin"));
|
|
@@ -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
|
+
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
handleAdminLoginPost,
|
|
9
9
|
handleAdminLogoutPost,
|
|
10
10
|
} from "../admin-handlers.ts";
|
|
11
|
+
import { _resetUnlockStateForTest, requireUnlocked, setPin } from "../admin-lock.ts";
|
|
11
12
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
12
13
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
14
|
import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
|
|
@@ -169,6 +170,33 @@ describe("handleAdminLoginPost", () => {
|
|
|
169
170
|
expect(res.headers.get("set-cookie") ?? "").toContain("parachute_hub_session=");
|
|
170
171
|
});
|
|
171
172
|
|
|
173
|
+
test("Fix B: a fresh login with a PIN configured records an unlock (no immediate lock)", async () => {
|
|
174
|
+
// The admin-lock PIN guards the idle/grabbed tab — NOT the instant after a
|
|
175
|
+
// full login. The operator just proved their password; re-gating on the PIN
|
|
176
|
+
// the moment after is pure friction. A freshly-minted session must land
|
|
177
|
+
// within an unlock window so the SPA doesn't show the lock screen.
|
|
178
|
+
_resetUnlockStateForTest();
|
|
179
|
+
await createUser(harness.db, "admin", "pw", { passwordChanged: true });
|
|
180
|
+
await setPin(harness.db, "4827"); // lock feature ON
|
|
181
|
+
const { body, headers } = formBody({
|
|
182
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
183
|
+
username: "admin",
|
|
184
|
+
password: "pw",
|
|
185
|
+
next: "/admin/permissions",
|
|
186
|
+
});
|
|
187
|
+
const req = new Request("http://hub.test/admin/login", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: { ...headers, cookie: CSRF_COOKIE },
|
|
190
|
+
body,
|
|
191
|
+
});
|
|
192
|
+
const res = await handleAdminLoginPost(harness.db, req);
|
|
193
|
+
expect(res.status).toBe(302);
|
|
194
|
+
const sid = (res.headers.get("set-cookie") ?? "").match(/parachute_hub_session=([^;]+)/)?.[1];
|
|
195
|
+
expect(sid?.length).toBeTruthy();
|
|
196
|
+
// The just-minted session is unlocked — recordLoginUnlock ran during login.
|
|
197
|
+
expect(requireUnlocked(harness.db, sid ?? "").ok).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
172
200
|
test("ignores an absolute-URL next= from the form", async () => {
|
|
173
201
|
await createUser(harness.db, "admin", "pw", { passwordChanged: true });
|
|
174
202
|
const { body, headers } = formBody({
|
|
@@ -18,7 +18,13 @@ import { HOST_ADMIN_TOKEN_TTL_SECONDS, handleHostAdminToken } from "../admin-hos
|
|
|
18
18
|
import { handleApiTokens } from "../api-tokens.ts";
|
|
19
19
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
20
|
import { validateAccessToken } from "../jwt-sign.ts";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
SESSION_TTL_MS,
|
|
23
|
+
buildSessionCookie,
|
|
24
|
+
createSession,
|
|
25
|
+
deleteSession,
|
|
26
|
+
findSession,
|
|
27
|
+
} from "../sessions.ts";
|
|
22
28
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
23
29
|
import { createUser } from "../users.ts";
|
|
24
30
|
|
|
@@ -186,6 +192,57 @@ describe("handleHostAdminToken", () => {
|
|
|
186
192
|
expect(validated.payload.sub).toBe(adminId);
|
|
187
193
|
});
|
|
188
194
|
|
|
195
|
+
// Sliding session renewal (Fix A) — a successful mint pushes the session's
|
|
196
|
+
// expiry forward and re-issues the cookie, so an active operator (the SPA
|
|
197
|
+
// re-mints ~every 10 min) isn't hard-logged-out at the 24h mark. The cookie
|
|
198
|
+
// must keep the EXACT attributes creation uses — not broadened.
|
|
199
|
+
test("200 renews the session cookie (HttpOnly/Secure/SameSite/host-only) and slides expiry", async () => {
|
|
200
|
+
const user = await createUser(harness.db, "operator", "hunter2");
|
|
201
|
+
// Create the session 12h in the past so the forward slide is observable.
|
|
202
|
+
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
|
|
203
|
+
const session = createSession(harness.db, { userId: user.id, now: () => twelveHoursAgo });
|
|
204
|
+
const originalExpiry = new Date(session.expiresAt).getTime();
|
|
205
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
206
|
+
rotateSigningKey(harness.db);
|
|
207
|
+
|
|
208
|
+
const res = await handleHostAdminToken(
|
|
209
|
+
new Request(`${ISSUER}/admin/host-admin-token`, { headers: { cookie } }),
|
|
210
|
+
{ db: harness.db, issuer: ISSUER },
|
|
211
|
+
);
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
215
|
+
expect(setCookie).toContain("parachute_hub_session=");
|
|
216
|
+
expect(setCookie).toContain("HttpOnly");
|
|
217
|
+
expect(setCookie).toContain("Secure"); // ISSUER is https → Secure kept
|
|
218
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
219
|
+
expect(setCookie).toContain("Path=/");
|
|
220
|
+
expect(setCookie).not.toContain("Path=/oauth");
|
|
221
|
+
// Host-only: the renewed cookie must NOT add a Domain (no broadening).
|
|
222
|
+
expect(setCookie.toLowerCase()).not.toContain("domain=");
|
|
223
|
+
|
|
224
|
+
// The session's expiry slid forward (touchSession ran on the success path).
|
|
225
|
+
const found = findSession(harness.db, session.id);
|
|
226
|
+
expect(new Date(found?.expiresAt ?? 0).getTime()).toBeGreaterThan(originalExpiry);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("renewed cookie omits Secure over plain HTTP (protocol-correct, not broadened)", async () => {
|
|
230
|
+
const { cookie } = await withSession();
|
|
231
|
+
rotateSigningKey(harness.db);
|
|
232
|
+
// HTTP origin → isHttpsRequest false → no Secure, so the browser keeps the
|
|
233
|
+
// cookie on http://localhost:1939 — mirrors how the session cookie is minted.
|
|
234
|
+
const res = await handleHostAdminToken(
|
|
235
|
+
new Request("http://hub.test/admin/host-admin-token", { headers: { cookie } }),
|
|
236
|
+
{ db: harness.db, issuer: ISSUER },
|
|
237
|
+
);
|
|
238
|
+
expect(res.status).toBe(200);
|
|
239
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
240
|
+
expect(setCookie).toContain("parachute_hub_session=");
|
|
241
|
+
expect(setCookie).not.toContain("Secure");
|
|
242
|
+
expect(setCookie).toContain("HttpOnly");
|
|
243
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
244
|
+
});
|
|
245
|
+
|
|
189
246
|
// Regression for the end-to-end bug that motivated adding `:host:auth`
|
|
190
247
|
// here: the SPA's session-bearer was rejected by `/api/auth/tokens` (and
|
|
191
248
|
// its peers) because it carried `:host:admin` only. This test mints
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
getIdleSeconds,
|
|
31
31
|
isLockConfigured,
|
|
32
32
|
isSessionUnlocked,
|
|
33
|
+
recordLoginUnlock,
|
|
33
34
|
recordUnlock,
|
|
34
35
|
refreshActivity,
|
|
35
36
|
requireUnlocked,
|
|
@@ -212,6 +213,31 @@ describe("requireUnlocked gate", () => {
|
|
|
212
213
|
});
|
|
213
214
|
});
|
|
214
215
|
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// recordLoginUnlock (Fix B) — unlock at the auth boundary so a fresh login
|
|
218
|
+
// doesn't immediately hit the PIN lock screen.
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
describe("recordLoginUnlock", () => {
|
|
222
|
+
test("opens an unlock window when a PIN is configured", async () => {
|
|
223
|
+
await setPin(harness.db, "4827");
|
|
224
|
+
// A fresh session with a PIN set but no unlock is locked.
|
|
225
|
+
expect(requireUnlocked(harness.db, "sid-login").ok).toBe(false);
|
|
226
|
+
recordLoginUnlock(harness.db, "sid-login");
|
|
227
|
+
// After login → the freshly-authenticated session is unlocked.
|
|
228
|
+
expect(requireUnlocked(harness.db, "sid-login").ok).toBe(true);
|
|
229
|
+
expect(isSessionUnlocked("sid-login")).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("no-op when the lock feature is OFF (no PIN) — records nothing", () => {
|
|
233
|
+
// Feature off → requireUnlocked is always ok anyway; the helper must not
|
|
234
|
+
// record a spurious window (meaningless, and would grow the map).
|
|
235
|
+
recordLoginUnlock(harness.db, "sid-no-pin");
|
|
236
|
+
expect(isSessionUnlocked("sid-no-pin")).toBe(false);
|
|
237
|
+
expect(requireUnlocked(harness.db, "sid-no-pin").ok).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
215
241
|
// ---------------------------------------------------------------------------
|
|
216
242
|
// The cascade: a locked session refuses ALL four admin-token mints.
|
|
217
243
|
// ---------------------------------------------------------------------------
|
|
@@ -527,7 +553,13 @@ describe("admin-lock management API", () => {
|
|
|
527
553
|
const r2 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
|
|
528
554
|
db: harness.db,
|
|
529
555
|
});
|
|
530
|
-
|
|
556
|
+
const body2 = (await r2.json()) as { locked: boolean; idle_seconds?: number };
|
|
557
|
+
expect(body2.locked).toBe(false);
|
|
558
|
+
// The heartbeat MUST carry idle_seconds — the client re-anchors its local
|
|
559
|
+
// idle timer from it on every heartbeat. Omitting it poisoned the timer
|
|
560
|
+
// (undefined → NaN → instant re-lock). Regression guard for the PIN
|
|
561
|
+
// re-prompt loop.
|
|
562
|
+
expect(body2.idle_seconds).toBe(getIdleSeconds(harness.db));
|
|
531
563
|
});
|
|
532
564
|
|
|
533
565
|
test("unlock brute-force limiter: 6th attempt is 429", async () => {
|
|
@@ -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
|
}
|