@openparachute/hub 0.6.3 → 0.6.4-rc.1
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__/account-setup.test.ts +609 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +125 -4
- package/src/__tests__/api-invites.test.ts +180 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +187 -1
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +5 -4
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +628 -13
- package/src/__tests__/hub-unit.test.ts +4 -0
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +32 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +177 -0
- package/src/__tests__/users.test.ts +27 -0
- package/src/account-home-ui.ts +82 -9
- package/src/account-setup.ts +342 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +27 -2
- package/src/admin-login-ui.ts +94 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +54 -1
- package/src/api-invites.ts +347 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +122 -32
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +399 -41
- package/src/hub-unit.ts +4 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +8 -3
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +84 -7
- package/src/users.ts +42 -4
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../hub-server.ts";
|
|
16
16
|
import { setNotesRedirectDisabled } from "../hub-settings.ts";
|
|
17
17
|
import { clearNotesRedirectLogState } from "../notes-redirect.ts";
|
|
18
|
+
import { mintOperatorToken } from "../operator-token.ts";
|
|
18
19
|
import { pidPath } from "../process-state.ts";
|
|
19
20
|
import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
|
|
20
21
|
import { buildSessionCookie, createSession } from "../sessions.ts";
|
|
@@ -530,9 +531,13 @@ describe("hubFetch routing", () => {
|
|
|
530
531
|
const h = makeHarness();
|
|
531
532
|
try {
|
|
532
533
|
writeManifest({ services: [vaultEntry("default")] }, h.manifestPath);
|
|
533
|
-
const res = await hubFetch(h.dir, {
|
|
534
|
-
|
|
535
|
-
|
|
534
|
+
const res = await hubFetch(h.dir, {
|
|
535
|
+
manifestPath: h.manifestPath,
|
|
536
|
+
// No exposure recorded — isolate from the host's real expose-state.json
|
|
537
|
+
// so the request-origin fallback (not the #531 expose tier) is what
|
|
538
|
+
// this test exercises.
|
|
539
|
+
loadExposeHubOrigin: () => undefined,
|
|
540
|
+
})(new Request("http://127.0.0.1:1939/.well-known/parachute.json"));
|
|
536
541
|
const body = (await res.json()) as { vaults: Array<{ url: string }> };
|
|
537
542
|
expect(body.vaults[0]?.url).toBe("http://127.0.0.1:1939/vault/default");
|
|
538
543
|
} finally {
|
|
@@ -3195,13 +3200,46 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
|
|
|
3195
3200
|
});
|
|
3196
3201
|
});
|
|
3197
3202
|
|
|
3198
|
-
describe("layerOf — classify trust layer from proxy headers", () => {
|
|
3199
|
-
//
|
|
3200
|
-
//
|
|
3201
|
-
// a
|
|
3203
|
+
describe("layerOf — classify trust layer from proxy headers + peer (item E / #526)", () => {
|
|
3204
|
+
// Proxy headers (cloudflared, tailscale serve/funnel) take precedence. When
|
|
3205
|
+
// absent, the PEER ADDRESS is the loopback discriminator — header-absence is
|
|
3206
|
+
// no longer a loopback signal (#526). The peer-address is the 2nd arg.
|
|
3207
|
+
|
|
3208
|
+
test("no proxy headers + loopback peer (127.0.0.1) → loopback (on-box CLI)", () => {
|
|
3209
|
+
expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
|
|
3210
|
+
});
|
|
3202
3211
|
|
|
3203
|
-
test("no proxy headers
|
|
3204
|
-
expect(layerOf(req("/"))).toBe("loopback");
|
|
3212
|
+
test("no proxy headers + IPv6 loopback peer (::1) → loopback", () => {
|
|
3213
|
+
expect(layerOf(req("/"), "::1")).toBe("loopback");
|
|
3214
|
+
});
|
|
3215
|
+
|
|
3216
|
+
test("no proxy headers + IPv4-mapped IPv6 loopback (::ffff:127.0.0.1) → loopback", () => {
|
|
3217
|
+
expect(layerOf(req("/"), "::ffff:127.0.0.1")).toBe("loopback");
|
|
3218
|
+
});
|
|
3219
|
+
|
|
3220
|
+
// THE FIX: a header-absent NON-loopback peer (the 0.0.0.0-bind direct-network
|
|
3221
|
+
// case) must NOT be classified loopback — it would bypass the
|
|
3222
|
+
// publicExposure:loopback 404-cloak. Fail to public (least-trusted).
|
|
3223
|
+
test("no proxy headers + non-loopback peer → public (NOT loopback) [#526]", () => {
|
|
3224
|
+
expect(layerOf(req("/"), "203.0.113.7")).toBe("public");
|
|
3225
|
+
expect(layerOf(req("/"), "10.0.0.5")).toBe("public");
|
|
3226
|
+
expect(layerOf(req("/"), "fd00::1234")).toBe("public");
|
|
3227
|
+
});
|
|
3228
|
+
|
|
3229
|
+
// Fail-closed: an unknown peer (no Server threaded — null/undefined) is NOT
|
|
3230
|
+
// loopback. A direct unit call to the fetch fn with no server lands here.
|
|
3231
|
+
test("no proxy headers + unknown peer (null/undefined) → public (fail closed)", () => {
|
|
3232
|
+
expect(layerOf(req("/"), null)).toBe("public");
|
|
3233
|
+
expect(layerOf(req("/"), undefined)).toBe("public");
|
|
3234
|
+
expect(layerOf(req("/"))).toBe("public");
|
|
3235
|
+
});
|
|
3236
|
+
|
|
3237
|
+
// Headers still win over peer address — a tailnet/public forwarder sets the
|
|
3238
|
+
// header and the peer (the local tailscaled/cloudflared) is loopback, but the
|
|
3239
|
+
// header is authoritative.
|
|
3240
|
+
test("Tailscale-User-Login → tailnet even from a loopback peer", () => {
|
|
3241
|
+
const r = req("/", { headers: { "Tailscale-User-Login": "alice@example.com" } });
|
|
3242
|
+
expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
|
|
3205
3243
|
});
|
|
3206
3244
|
|
|
3207
3245
|
test("Tailscale-User-Login → tailnet (authed via tailscale serve)", () => {
|
|
@@ -3266,6 +3304,11 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3266
3304
|
return { port: server.port as number, stop: () => server.stop(true) };
|
|
3267
3305
|
}
|
|
3268
3306
|
|
|
3307
|
+
// Fake Bun Server handle exposing only `requestIP` (item E / #526) so the
|
|
3308
|
+
// fetch fn can resolve the peer address. The on-box CLI caller connects from
|
|
3309
|
+
// 127.0.0.1; a network peer on a 0.0.0.0 bind connects from its real IP.
|
|
3310
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
3311
|
+
|
|
3269
3312
|
test("publicExposure: loopback + tailnet header → 404 (gate hides the route)", async () => {
|
|
3270
3313
|
const h = makeHarness();
|
|
3271
3314
|
const upstream = startUpstream("loopback-only");
|
|
@@ -3326,7 +3369,7 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3326
3369
|
}
|
|
3327
3370
|
});
|
|
3328
3371
|
|
|
3329
|
-
test("publicExposure: loopback + no headers → reaches upstream (loopback layer)", async () => {
|
|
3372
|
+
test("publicExposure: loopback + no headers + loopback peer → reaches upstream (loopback layer)", async () => {
|
|
3330
3373
|
const h = makeHarness();
|
|
3331
3374
|
const upstream = startUpstream("loopback-only");
|
|
3332
3375
|
try {
|
|
@@ -3346,7 +3389,8 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3346
3389
|
h.manifestPath,
|
|
3347
3390
|
);
|
|
3348
3391
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3349
|
-
|
|
3392
|
+
// On-box CLI caller: 127.0.0.1 peer, no proxy headers → loopback layer.
|
|
3393
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
|
|
3350
3394
|
expect(res.status).toBe(200);
|
|
3351
3395
|
const body = (await res.json()) as { tag: string };
|
|
3352
3396
|
expect(body.tag).toBe("loopback-only");
|
|
@@ -3356,6 +3400,37 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3356
3400
|
}
|
|
3357
3401
|
});
|
|
3358
3402
|
|
|
3403
|
+
// Item E / #526 — the core fix. On a 0.0.0.0 bind a network peer reaches the
|
|
3404
|
+
// listener with NO proxy headers; it must NOT be treated as loopback, so the
|
|
3405
|
+
// loopback-exposure cloak still fires (404) rather than leaking the route.
|
|
3406
|
+
test("publicExposure: loopback + no headers + NON-loopback peer → 404 (cloak fires) [#526]", async () => {
|
|
3407
|
+
const h = makeHarness();
|
|
3408
|
+
const upstream = startUpstream("loopback-only");
|
|
3409
|
+
try {
|
|
3410
|
+
writeManifest(
|
|
3411
|
+
{
|
|
3412
|
+
services: [
|
|
3413
|
+
{
|
|
3414
|
+
name: "loopback-only",
|
|
3415
|
+
port: upstream.port,
|
|
3416
|
+
paths: ["/loopback-only"],
|
|
3417
|
+
health: "/loopback-only/health",
|
|
3418
|
+
version: "0.1.0",
|
|
3419
|
+
publicExposure: "loopback",
|
|
3420
|
+
},
|
|
3421
|
+
],
|
|
3422
|
+
},
|
|
3423
|
+
h.manifestPath,
|
|
3424
|
+
);
|
|
3425
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3426
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("203.0.113.9"));
|
|
3427
|
+
expect(res.status).toBe(404);
|
|
3428
|
+
} finally {
|
|
3429
|
+
upstream.stop();
|
|
3430
|
+
h.cleanup();
|
|
3431
|
+
}
|
|
3432
|
+
});
|
|
3433
|
+
|
|
3359
3434
|
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
3360
3435
|
const h = makeHarness();
|
|
3361
3436
|
const upstream = startUpstream("allowed");
|
|
@@ -3514,6 +3589,10 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3514
3589
|
return { port: server.port as number, stop: () => server.stop(true) };
|
|
3515
3590
|
}
|
|
3516
3591
|
|
|
3592
|
+
// Item E / #526 — fake Bun Server handle exposing `requestIP` for the peer-
|
|
3593
|
+
// address discriminator (see the proxyToService block for the rationale).
|
|
3594
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
3595
|
+
|
|
3517
3596
|
test("vault publicExposure: loopback + tailnet header → 404", async () => {
|
|
3518
3597
|
const h = makeHarness();
|
|
3519
3598
|
const upstream = startVaultUpstream("vault-private");
|
|
@@ -3545,7 +3624,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3545
3624
|
}
|
|
3546
3625
|
});
|
|
3547
3626
|
|
|
3548
|
-
test("vault publicExposure: loopback + no headers → reaches vault backend", async () => {
|
|
3627
|
+
test("vault publicExposure: loopback + no headers + loopback peer → reaches vault backend", async () => {
|
|
3549
3628
|
const h = makeHarness();
|
|
3550
3629
|
const upstream = startVaultUpstream("vault-private");
|
|
3551
3630
|
try {
|
|
@@ -3565,7 +3644,7 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3565
3644
|
h.manifestPath,
|
|
3566
3645
|
);
|
|
3567
3646
|
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3568
|
-
const res = await fetcher(req("/vault/private/health"));
|
|
3647
|
+
const res = await fetcher(req("/vault/private/health"), fakeServer("127.0.0.1"));
|
|
3569
3648
|
expect(res.status).toBe(200);
|
|
3570
3649
|
const body = (await res.json()) as { tag: string };
|
|
3571
3650
|
expect(body.tag).toBe("vault-private");
|
|
@@ -3575,6 +3654,36 @@ describe("hubFetch publicExposure layer-gate (proxyToVault)", () => {
|
|
|
3575
3654
|
}
|
|
3576
3655
|
});
|
|
3577
3656
|
|
|
3657
|
+
// Item E / #526 — vault-path symmetry: a header-absent NON-loopback peer on a
|
|
3658
|
+
// 0.0.0.0 bind must NOT reach a loopback-exposed vault (cloak fires).
|
|
3659
|
+
test("vault publicExposure: loopback + no headers + NON-loopback peer → 404 [#526]", async () => {
|
|
3660
|
+
const h = makeHarness();
|
|
3661
|
+
const upstream = startVaultUpstream("vault-private");
|
|
3662
|
+
try {
|
|
3663
|
+
writeManifest(
|
|
3664
|
+
{
|
|
3665
|
+
services: [
|
|
3666
|
+
{
|
|
3667
|
+
name: "parachute-vault-private",
|
|
3668
|
+
port: upstream.port,
|
|
3669
|
+
paths: ["/vault/private"],
|
|
3670
|
+
health: "/vault/private/health",
|
|
3671
|
+
version: "0.4.0",
|
|
3672
|
+
publicExposure: "loopback",
|
|
3673
|
+
},
|
|
3674
|
+
],
|
|
3675
|
+
},
|
|
3676
|
+
h.manifestPath,
|
|
3677
|
+
);
|
|
3678
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3679
|
+
const res = await fetcher(req("/vault/private/health"), fakeServer("198.51.100.4"));
|
|
3680
|
+
expect(res.status).toBe(404);
|
|
3681
|
+
} finally {
|
|
3682
|
+
upstream.stop();
|
|
3683
|
+
h.cleanup();
|
|
3684
|
+
}
|
|
3685
|
+
});
|
|
3686
|
+
|
|
3578
3687
|
test("vault publicExposure: allowed + tailnet header → reaches backend", async () => {
|
|
3579
3688
|
const h = makeHarness();
|
|
3580
3689
|
const upstream = startVaultUpstream("vault-public");
|
|
@@ -4185,6 +4294,9 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4185
4294
|
const friend = await createUser(db, "friend", "friend-password-123", {
|
|
4186
4295
|
assignedVaults,
|
|
4187
4296
|
allowMulti: true,
|
|
4297
|
+
// Item F (#469): the friend mints a token only after rotating the temp
|
|
4298
|
+
// password; an unrotated friend is force-redirected before any mint.
|
|
4299
|
+
passwordChanged: true,
|
|
4188
4300
|
});
|
|
4189
4301
|
const session = createSession(db, { userId: friend.id });
|
|
4190
4302
|
const csrf = generateCsrfToken();
|
|
@@ -4227,6 +4339,63 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4227
4339
|
}
|
|
4228
4340
|
});
|
|
4229
4341
|
|
|
4342
|
+
// Item F (#469) routed e2e — an assigned but unrotated friend is force-
|
|
4343
|
+
// redirected to the change-password rail before any mint, through the real
|
|
4344
|
+
// dispatch.
|
|
4345
|
+
test("unrotated friend is force-change-gated, routed through hubFetch (item F / #469)", async () => {
|
|
4346
|
+
// As of hub#469 the broad per-request gate (forceChangePasswordGate) is the
|
|
4347
|
+
// choke point and intercepts BEFORE the per-route mint handler. A browser
|
|
4348
|
+
// request (Accept: text/html) is 302'd to the change-password rail; an
|
|
4349
|
+
// API-style POST without that header is 403 force_change_password. Both
|
|
4350
|
+
// prove the unrotated friend can't parlay the temp password into a mint.
|
|
4351
|
+
const h = makeHarness();
|
|
4352
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4353
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4354
|
+
rotateSigningKey(db);
|
|
4355
|
+
await createUser(db, "operator", "operator-password-123");
|
|
4356
|
+
const friend = await createUser(db, "friend", "friend-password-123", {
|
|
4357
|
+
assignedVaults: ["work"],
|
|
4358
|
+
allowMulti: true,
|
|
4359
|
+
passwordChanged: false, // not yet rotated
|
|
4360
|
+
});
|
|
4361
|
+
const session = createSession(db, { userId: friend.id });
|
|
4362
|
+
const csrf = generateCsrfToken();
|
|
4363
|
+
const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
|
|
4364
|
+
buildCsrfCookie(csrf, { secure: false }).split(";")[0]
|
|
4365
|
+
}`;
|
|
4366
|
+
const fetcher = hubFetch(h.dir, {
|
|
4367
|
+
getDb: () => db,
|
|
4368
|
+
manifestPath: h.manifestPath,
|
|
4369
|
+
issuer: "https://hub.test",
|
|
4370
|
+
});
|
|
4371
|
+
try {
|
|
4372
|
+
// The mint is a POST (non-GET) → the gate rejects with 403
|
|
4373
|
+
// force_change_password regardless of Accept, per the spec ("redirect
|
|
4374
|
+
// browser GETs, reject non-GET / API-style requests with 403"). A 302 on
|
|
4375
|
+
// a POST wouldn't usefully re-issue the mint anyway.
|
|
4376
|
+
const apiRes = await fetcher(
|
|
4377
|
+
req("/account/vault-token/work", {
|
|
4378
|
+
method: "POST",
|
|
4379
|
+
headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
|
|
4380
|
+
body: postBody(csrf, "read"),
|
|
4381
|
+
}),
|
|
4382
|
+
);
|
|
4383
|
+
expect(apiRes.status).toBe(403);
|
|
4384
|
+
expect(((await apiRes.json()) as { error: string }).error).toBe("force_change_password");
|
|
4385
|
+
|
|
4386
|
+
// The friend's browser GET of the account home IS bounced to the
|
|
4387
|
+
// change-password rail — the surface they'd navigate to is gated.
|
|
4388
|
+
const browserRes = await fetcher(
|
|
4389
|
+
req("/account/", { headers: { cookie, accept: "text/html" } }),
|
|
4390
|
+
);
|
|
4391
|
+
expect(browserRes.status).toBe(302);
|
|
4392
|
+
expect(browserRes.headers.get("location")).toBe("/account/change-password");
|
|
4393
|
+
} finally {
|
|
4394
|
+
db.close();
|
|
4395
|
+
h.cleanup();
|
|
4396
|
+
}
|
|
4397
|
+
});
|
|
4398
|
+
|
|
4230
4399
|
test("unassigned vault → 403, no token, routed through hubFetch", async () => {
|
|
4231
4400
|
const h = makeHarness();
|
|
4232
4401
|
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
@@ -4269,6 +4438,73 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4269
4438
|
});
|
|
4270
4439
|
});
|
|
4271
4440
|
|
|
4441
|
+
// Item D (#450) routed e2e — exercise the knownVaultNames threading from
|
|
4442
|
+
// services.json → hubFetch → handleApiMintToken (hub-server.ts dispatch),
|
|
4443
|
+
// which the unit-level handler tests can't cover. A `vault:<typo>:admin` mint
|
|
4444
|
+
// for an unregistered vault → 400 through the full stack; a known vault → 200.
|
|
4445
|
+
describe("POST /api/auth/mint-token — vault-existence threading (routed end-to-end, item D)", () => {
|
|
4446
|
+
const ISSUER = "https://hub.test";
|
|
4447
|
+
|
|
4448
|
+
async function seedMint(
|
|
4449
|
+
h: Harness,
|
|
4450
|
+
vaultNames: string[],
|
|
4451
|
+
): Promise<{ db: ReturnType<typeof openHubDb>; bearer: string }> {
|
|
4452
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4453
|
+
rotateSigningKey(db); // mint needs an active signing key
|
|
4454
|
+
const owner = await createUser(db, "owner", "owner-password-123");
|
|
4455
|
+
// The default operator scope-set carries parachute:host:admin, which mints
|
|
4456
|
+
// vault:<name>:admin (canGrant rule 2).
|
|
4457
|
+
const op = await mintOperatorToken(db, owner.id, { issuer: ISSUER });
|
|
4458
|
+
writeManifest({ services: vaultNames.map((n) => vaultEntry(n)) }, h.manifestPath);
|
|
4459
|
+
return { db, bearer: op.token };
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
function mintReq(scope: string, bearer: string): Request {
|
|
4463
|
+
return req("/api/auth/mint-token", {
|
|
4464
|
+
method: "POST",
|
|
4465
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
4466
|
+
body: JSON.stringify({ scope }),
|
|
4467
|
+
});
|
|
4468
|
+
}
|
|
4469
|
+
|
|
4470
|
+
test("vault:<typo>:admin for an unregistered vault → 400 (knownVaultNames from services.json)", async () => {
|
|
4471
|
+
const h = makeHarness();
|
|
4472
|
+
const { db, bearer } = await seedMint(h, ["work", "default"]);
|
|
4473
|
+
try {
|
|
4474
|
+
const res = await hubFetch(h.dir, {
|
|
4475
|
+
getDb: () => db,
|
|
4476
|
+
manifestPath: h.manifestPath,
|
|
4477
|
+
issuer: ISSUER,
|
|
4478
|
+
})(mintReq("vault:typo:admin", bearer));
|
|
4479
|
+
expect(res.status).toBe(400);
|
|
4480
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
4481
|
+
expect(body.error).toBe("invalid_scope");
|
|
4482
|
+
expect(body.error_description).toContain("typo");
|
|
4483
|
+
} finally {
|
|
4484
|
+
db.close();
|
|
4485
|
+
h.cleanup();
|
|
4486
|
+
}
|
|
4487
|
+
});
|
|
4488
|
+
|
|
4489
|
+
test("vault:<name>:admin for a REGISTERED vault → 200 (proves the gate isn't over-blocking)", async () => {
|
|
4490
|
+
const h = makeHarness();
|
|
4491
|
+
const { db, bearer } = await seedMint(h, ["work", "default"]);
|
|
4492
|
+
try {
|
|
4493
|
+
const res = await hubFetch(h.dir, {
|
|
4494
|
+
getDb: () => db,
|
|
4495
|
+
manifestPath: h.manifestPath,
|
|
4496
|
+
issuer: ISSUER,
|
|
4497
|
+
})(mintReq("vault:work:admin", bearer));
|
|
4498
|
+
expect(res.status).toBe(200);
|
|
4499
|
+
const body = (await res.json()) as { scope: string };
|
|
4500
|
+
expect(body.scope).toBe("vault:work:admin");
|
|
4501
|
+
} finally {
|
|
4502
|
+
db.close();
|
|
4503
|
+
h.cleanup();
|
|
4504
|
+
}
|
|
4505
|
+
});
|
|
4506
|
+
});
|
|
4507
|
+
|
|
4272
4508
|
describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
|
|
4273
4509
|
test("POST /api/hub/upgrade dispatches to the handler (401 without bearer, NOT 404)", async () => {
|
|
4274
4510
|
const h = makeHarness();
|
|
@@ -4332,3 +4568,382 @@ describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
|
|
|
4332
4568
|
}
|
|
4333
4569
|
});
|
|
4334
4570
|
});
|
|
4571
|
+
|
|
4572
|
+
// Per-request force-change-password enforcement (P0-1 / hub#469). The redirect
|
|
4573
|
+
// at /login was never enough: a signed-in user holding an admin-set temp
|
|
4574
|
+
// password (`password_changed=false`) could navigate DIRECTLY to /account/* or
|
|
4575
|
+
// a per-vault proxy URL and operate indefinitely on the un-rotated secret.
|
|
4576
|
+
// These tests pin the broad per-request gate: pre-rotation users are bounced
|
|
4577
|
+
// off every /account/* surface AND the per-vault proxy, EXCEPT the rotation/exit
|
|
4578
|
+
// path (/account/change-password + /logout); after rotation all surfaces open.
|
|
4579
|
+
describe("force-change-password per-request gate (#469)", () => {
|
|
4580
|
+
// Seed a signed-in user with a chosen password_changed flag and return a
|
|
4581
|
+
// ready-to-use session cookie. Mirrors the seedFriend helpers in the
|
|
4582
|
+
// account-vault-{token,admin-token} suites.
|
|
4583
|
+
async function seedUser(
|
|
4584
|
+
h: Harness,
|
|
4585
|
+
db: ReturnType<typeof openHubDb>,
|
|
4586
|
+
opts: { passwordChanged: boolean; assignedVaults?: string[] },
|
|
4587
|
+
): Promise<{ cookie: string }> {
|
|
4588
|
+
const { SESSION_TTL_MS } = await import("../sessions.ts");
|
|
4589
|
+
const user = await createUser(db, "friend", "temp-pw", {
|
|
4590
|
+
allowMulti: true,
|
|
4591
|
+
passwordChanged: opts.passwordChanged,
|
|
4592
|
+
assignedVaults: opts.assignedVaults ?? [],
|
|
4593
|
+
});
|
|
4594
|
+
const session = createSession(db, { userId: user.id });
|
|
4595
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
4596
|
+
return { cookie };
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4599
|
+
test("(a) pre-rotation browser GET /account/ is 302'd to change-password", async () => {
|
|
4600
|
+
const h = makeHarness();
|
|
4601
|
+
try {
|
|
4602
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4603
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4604
|
+
try {
|
|
4605
|
+
const { cookie } = await seedUser(h, db, {
|
|
4606
|
+
passwordChanged: false,
|
|
4607
|
+
assignedVaults: ["work"],
|
|
4608
|
+
});
|
|
4609
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4610
|
+
req("/account/", { headers: { cookie, accept: "text/html" } }),
|
|
4611
|
+
);
|
|
4612
|
+
expect(res.status).toBe(302);
|
|
4613
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
4614
|
+
} finally {
|
|
4615
|
+
db.close();
|
|
4616
|
+
}
|
|
4617
|
+
} finally {
|
|
4618
|
+
h.cleanup();
|
|
4619
|
+
}
|
|
4620
|
+
});
|
|
4621
|
+
|
|
4622
|
+
test("(a) pre-rotation API POST /account/vault-token/<name> is 403 force_change_password", async () => {
|
|
4623
|
+
const h = makeHarness();
|
|
4624
|
+
try {
|
|
4625
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4626
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4627
|
+
try {
|
|
4628
|
+
const { cookie } = await seedUser(h, db, {
|
|
4629
|
+
passwordChanged: false,
|
|
4630
|
+
assignedVaults: ["work"],
|
|
4631
|
+
});
|
|
4632
|
+
// No Accept: text/html → treated as an API client → 403 JSON, not a 302.
|
|
4633
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4634
|
+
req("/account/vault-token/work", { method: "POST", headers: { cookie } }),
|
|
4635
|
+
);
|
|
4636
|
+
expect(res.status).toBe(403);
|
|
4637
|
+
const body = (await res.json()) as { error: string };
|
|
4638
|
+
expect(body.error).toBe("force_change_password");
|
|
4639
|
+
} finally {
|
|
4640
|
+
db.close();
|
|
4641
|
+
}
|
|
4642
|
+
} finally {
|
|
4643
|
+
h.cleanup();
|
|
4644
|
+
}
|
|
4645
|
+
});
|
|
4646
|
+
|
|
4647
|
+
test("(a) pre-rotation browser GET of a per-vault proxy URL is 302'd to change-password", async () => {
|
|
4648
|
+
const h = makeHarness();
|
|
4649
|
+
try {
|
|
4650
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4651
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4652
|
+
try {
|
|
4653
|
+
const { cookie } = await seedUser(h, db, {
|
|
4654
|
+
passwordChanged: false,
|
|
4655
|
+
assignedVaults: ["work"],
|
|
4656
|
+
});
|
|
4657
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4658
|
+
req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
|
|
4659
|
+
);
|
|
4660
|
+
// Gated BEFORE the proxy ever runs — so this is the gate's 302, not a
|
|
4661
|
+
// proxy 404/502.
|
|
4662
|
+
expect(res.status).toBe(302);
|
|
4663
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
4664
|
+
} finally {
|
|
4665
|
+
db.close();
|
|
4666
|
+
}
|
|
4667
|
+
} finally {
|
|
4668
|
+
h.cleanup();
|
|
4669
|
+
}
|
|
4670
|
+
});
|
|
4671
|
+
|
|
4672
|
+
test("(b) pre-rotation user CAN still reach /account/change-password (GET)", async () => {
|
|
4673
|
+
const h = makeHarness();
|
|
4674
|
+
try {
|
|
4675
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4676
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4677
|
+
try {
|
|
4678
|
+
const { cookie } = await seedUser(h, db, {
|
|
4679
|
+
passwordChanged: false,
|
|
4680
|
+
assignedVaults: ["work"],
|
|
4681
|
+
});
|
|
4682
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4683
|
+
req("/account/change-password", { headers: { cookie, accept: "text/html" } }),
|
|
4684
|
+
);
|
|
4685
|
+
// Reaches the real handler (200 form render) — NOT bounced to itself.
|
|
4686
|
+
expect(res.status).toBe(200);
|
|
4687
|
+
const body = await res.text();
|
|
4688
|
+
expect(body.toLowerCase()).toContain("password");
|
|
4689
|
+
} finally {
|
|
4690
|
+
db.close();
|
|
4691
|
+
}
|
|
4692
|
+
} finally {
|
|
4693
|
+
h.cleanup();
|
|
4694
|
+
}
|
|
4695
|
+
});
|
|
4696
|
+
|
|
4697
|
+
test("(b) pre-rotation user CAN still reach /logout (POST)", async () => {
|
|
4698
|
+
const h = makeHarness();
|
|
4699
|
+
try {
|
|
4700
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4701
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4702
|
+
try {
|
|
4703
|
+
const { cookie } = await seedUser(h, db, {
|
|
4704
|
+
passwordChanged: false,
|
|
4705
|
+
assignedVaults: ["work"],
|
|
4706
|
+
});
|
|
4707
|
+
// CSRF cookie + matching field so the logout POST passes its own gate;
|
|
4708
|
+
// the point is the force-change gate does NOT intercept /logout.
|
|
4709
|
+
const csrf = generateCsrfToken();
|
|
4710
|
+
const form = new URLSearchParams();
|
|
4711
|
+
form.set("__csrf", csrf);
|
|
4712
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4713
|
+
req("/logout", {
|
|
4714
|
+
method: "POST",
|
|
4715
|
+
headers: {
|
|
4716
|
+
cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
|
|
4717
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4718
|
+
},
|
|
4719
|
+
body: form.toString(),
|
|
4720
|
+
}),
|
|
4721
|
+
);
|
|
4722
|
+
// Logout succeeds (302 to /) — it is NOT the force-change 302 to
|
|
4723
|
+
// /account/change-password and NOT a 403.
|
|
4724
|
+
expect(res.status).toBe(302);
|
|
4725
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4726
|
+
expect(res.status).not.toBe(403);
|
|
4727
|
+
} finally {
|
|
4728
|
+
db.close();
|
|
4729
|
+
}
|
|
4730
|
+
} finally {
|
|
4731
|
+
h.cleanup();
|
|
4732
|
+
}
|
|
4733
|
+
});
|
|
4734
|
+
|
|
4735
|
+
test("(c) after rotation, /account/ is reachable (not gated)", async () => {
|
|
4736
|
+
const h = makeHarness();
|
|
4737
|
+
try {
|
|
4738
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4739
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4740
|
+
try {
|
|
4741
|
+
const { cookie } = await seedUser(h, db, {
|
|
4742
|
+
passwordChanged: true,
|
|
4743
|
+
assignedVaults: ["work"],
|
|
4744
|
+
});
|
|
4745
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4746
|
+
req("/account/", { headers: { cookie, accept: "text/html" } }),
|
|
4747
|
+
);
|
|
4748
|
+
// Account home renders — not bounced.
|
|
4749
|
+
expect(res.status).toBe(200);
|
|
4750
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4751
|
+
} finally {
|
|
4752
|
+
db.close();
|
|
4753
|
+
}
|
|
4754
|
+
} finally {
|
|
4755
|
+
h.cleanup();
|
|
4756
|
+
}
|
|
4757
|
+
});
|
|
4758
|
+
|
|
4759
|
+
test("(c) after rotation, a per-vault proxy URL is NOT gated (reaches the proxy)", async () => {
|
|
4760
|
+
const h = makeHarness();
|
|
4761
|
+
try {
|
|
4762
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4763
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4764
|
+
try {
|
|
4765
|
+
const { cookie } = await seedUser(h, db, {
|
|
4766
|
+
passwordChanged: true,
|
|
4767
|
+
assignedVaults: ["work"],
|
|
4768
|
+
});
|
|
4769
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4770
|
+
req("/vault/work/notes/abc", { headers: { cookie, accept: "text/html" } }),
|
|
4771
|
+
);
|
|
4772
|
+
// No upstream listening → the proxy returns a 404/502, NOT the gate's
|
|
4773
|
+
// 302→change-password / 403. The point is the gate let it through.
|
|
4774
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4775
|
+
const body = res.status === 403 ? ((await res.json()) as { error?: string }) : null;
|
|
4776
|
+
expect(body?.error).not.toBe("force_change_password");
|
|
4777
|
+
} finally {
|
|
4778
|
+
db.close();
|
|
4779
|
+
}
|
|
4780
|
+
} finally {
|
|
4781
|
+
h.cleanup();
|
|
4782
|
+
}
|
|
4783
|
+
});
|
|
4784
|
+
|
|
4785
|
+
test("an UNAUTHENTICATED per-vault proxy request is NOT gated (no hub session)", async () => {
|
|
4786
|
+
const h = makeHarness();
|
|
4787
|
+
try {
|
|
4788
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4789
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4790
|
+
try {
|
|
4791
|
+
// No cookie at all — the common Notes/MCP case carrying its own bearer.
|
|
4792
|
+
// forceChangePasswordGate returns null (no session) → proxy handles it.
|
|
4793
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4794
|
+
req("/vault/work/notes/abc", { headers: { accept: "text/html" } }),
|
|
4795
|
+
);
|
|
4796
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4797
|
+
expect(res.status).not.toBe(403);
|
|
4798
|
+
} finally {
|
|
4799
|
+
db.close();
|
|
4800
|
+
}
|
|
4801
|
+
} finally {
|
|
4802
|
+
h.cleanup();
|
|
4803
|
+
}
|
|
4804
|
+
});
|
|
4805
|
+
|
|
4806
|
+
test("(b) pre-rotation user CAN POST /account/change-password (the rotation action itself)", async () => {
|
|
4807
|
+
// The exempt POST: a pre-rotation user submitting their new password must
|
|
4808
|
+
// NOT be intercepted by the gate — it's the only way out of force-change.
|
|
4809
|
+
// `/account/change-password` is dispatched ABOVE the gate, so it never
|
|
4810
|
+
// reaches the choke point. We assert the POST reaches its own handler (it
|
|
4811
|
+
// fails CSRF here → its OWN 400/403, NOT the gate's 302→change-password).
|
|
4812
|
+
const h = makeHarness();
|
|
4813
|
+
try {
|
|
4814
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4815
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4816
|
+
try {
|
|
4817
|
+
const { cookie } = await seedUser(h, db, {
|
|
4818
|
+
passwordChanged: false,
|
|
4819
|
+
assignedVaults: ["work"],
|
|
4820
|
+
});
|
|
4821
|
+
const csrf = generateCsrfToken();
|
|
4822
|
+
const form = new URLSearchParams();
|
|
4823
|
+
form.set("__csrf", csrf);
|
|
4824
|
+
form.set("current_password", "temp-pw");
|
|
4825
|
+
form.set("new_password", "rotated-password-123");
|
|
4826
|
+
form.set("new_password_confirm", "rotated-password-123");
|
|
4827
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4828
|
+
req("/account/change-password", {
|
|
4829
|
+
method: "POST",
|
|
4830
|
+
headers: {
|
|
4831
|
+
cookie: `${cookie}; ${buildCsrfCookie(csrf)}`,
|
|
4832
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
4833
|
+
accept: "text/html",
|
|
4834
|
+
},
|
|
4835
|
+
body: form.toString(),
|
|
4836
|
+
}),
|
|
4837
|
+
);
|
|
4838
|
+
// Reaches its own handler (303 back to /account/ on success, or its own
|
|
4839
|
+
// form re-render). The point: it is NOT the gate's 302 to
|
|
4840
|
+
// change-password and NOT the gate's 403 force_change_password.
|
|
4841
|
+
expect(res.status).not.toBe(403);
|
|
4842
|
+
if (res.status === 302 || res.status === 303) {
|
|
4843
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4844
|
+
}
|
|
4845
|
+
// And the rotation actually took: the user's flag flipped to true.
|
|
4846
|
+
const { getUserById } = await import("../users.ts");
|
|
4847
|
+
const friend = getUserById(
|
|
4848
|
+
db,
|
|
4849
|
+
db.query<{ id: string }, []>("SELECT id FROM users WHERE username = 'friend'").get()
|
|
4850
|
+
?.id ?? "",
|
|
4851
|
+
);
|
|
4852
|
+
expect(friend?.passwordChanged).toBe(true);
|
|
4853
|
+
} finally {
|
|
4854
|
+
db.close();
|
|
4855
|
+
}
|
|
4856
|
+
} finally {
|
|
4857
|
+
h.cleanup();
|
|
4858
|
+
}
|
|
4859
|
+
});
|
|
4860
|
+
|
|
4861
|
+
test("(a) bare /account (no trailing slash) is gated on the FIRST hop for a pre-rotation user", async () => {
|
|
4862
|
+
// The bare `/account` 301s to `/account/`; without an explicit match it
|
|
4863
|
+
// would slip past `startsWith("/account/")` and only be gated on the second
|
|
4864
|
+
// hop. The gate must intercept the first request.
|
|
4865
|
+
const h = makeHarness();
|
|
4866
|
+
try {
|
|
4867
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4868
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4869
|
+
try {
|
|
4870
|
+
const { cookie } = await seedUser(h, db, {
|
|
4871
|
+
passwordChanged: false,
|
|
4872
|
+
assignedVaults: ["work"],
|
|
4873
|
+
});
|
|
4874
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4875
|
+
req("/account", { headers: { cookie, accept: "text/html" } }),
|
|
4876
|
+
);
|
|
4877
|
+
// Gated → 302 to change-password, NOT the 301 → /account/.
|
|
4878
|
+
expect(res.status).toBe(302);
|
|
4879
|
+
expect(res.headers.get("location")).toBe("/account/change-password");
|
|
4880
|
+
} finally {
|
|
4881
|
+
db.close();
|
|
4882
|
+
}
|
|
4883
|
+
} finally {
|
|
4884
|
+
h.cleanup();
|
|
4885
|
+
}
|
|
4886
|
+
});
|
|
4887
|
+
|
|
4888
|
+
test("(a) pre-rotation session at /oauth/authorize is 302'd to change-password (no auth code issued)", async () => {
|
|
4889
|
+
// The important one: a signed-in pre-rotation user must not ride the
|
|
4890
|
+
// consent flow to an auth code → /oauth/token exchange for a vault token
|
|
4891
|
+
// WITHOUT rotating. The gate intercepts /oauth/authorize before any client
|
|
4892
|
+
// validation or code issuance, so no `code=` redirect can be produced.
|
|
4893
|
+
const h = makeHarness();
|
|
4894
|
+
try {
|
|
4895
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4896
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4897
|
+
try {
|
|
4898
|
+
const { cookie } = await seedUser(h, db, {
|
|
4899
|
+
passwordChanged: false,
|
|
4900
|
+
assignedVaults: ["work"],
|
|
4901
|
+
});
|
|
4902
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4903
|
+
req(
|
|
4904
|
+
"/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
|
|
4905
|
+
{ headers: { cookie, accept: "text/html" } },
|
|
4906
|
+
),
|
|
4907
|
+
);
|
|
4908
|
+
expect(res.status).toBe(302);
|
|
4909
|
+
const location = res.headers.get("location") ?? "";
|
|
4910
|
+
expect(location).toBe("/account/change-password");
|
|
4911
|
+
// No auth code was issued — the redirect is to the rotation rail, not a
|
|
4912
|
+
// client callback carrying a `code=`.
|
|
4913
|
+
expect(location).not.toContain("code=");
|
|
4914
|
+
expect(location).not.toContain("app.example");
|
|
4915
|
+
} finally {
|
|
4916
|
+
db.close();
|
|
4917
|
+
}
|
|
4918
|
+
} finally {
|
|
4919
|
+
h.cleanup();
|
|
4920
|
+
}
|
|
4921
|
+
});
|
|
4922
|
+
|
|
4923
|
+
test("(c) after rotation, /oauth/authorize is NOT gated (reaches the oauth handler)", async () => {
|
|
4924
|
+
const h = makeHarness();
|
|
4925
|
+
try {
|
|
4926
|
+
writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
|
|
4927
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4928
|
+
try {
|
|
4929
|
+
const { cookie } = await seedUser(h, db, {
|
|
4930
|
+
passwordChanged: true,
|
|
4931
|
+
assignedVaults: ["work"],
|
|
4932
|
+
});
|
|
4933
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4934
|
+
req(
|
|
4935
|
+
"/oauth/authorize?client_id=x&response_type=code&redirect_uri=https://app.example/cb&scope=vault:work:read",
|
|
4936
|
+
{ headers: { cookie, accept: "text/html" } },
|
|
4937
|
+
),
|
|
4938
|
+
);
|
|
4939
|
+
// Reaches the real authorize handler (login/consent/error) — NOT bounced
|
|
4940
|
+
// to the change-password rail by the gate.
|
|
4941
|
+
expect(res.headers.get("location")).not.toBe("/account/change-password");
|
|
4942
|
+
} finally {
|
|
4943
|
+
db.close();
|
|
4944
|
+
}
|
|
4945
|
+
} finally {
|
|
4946
|
+
h.cleanup();
|
|
4947
|
+
}
|
|
4948
|
+
});
|
|
4949
|
+
});
|