@openparachute/hub 0.5.13-rc.4 → 0.5.13-rc.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -4
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +187 -0
- package/src/__tests__/api-account.test.ts +167 -0
- package/src/__tests__/api-hub.test.ts +251 -0
- package/src/__tests__/api-modules-config.test.ts +139 -4
- package/src/__tests__/api-modules-ops.test.ts +339 -4
- package/src/__tests__/api-modules.test.ts +337 -4
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-origin-resolution.test.ts +50 -0
- package/src/__tests__/hub-server.test.ts +506 -65
- package/src/__tests__/hub-settings.test.ts +65 -0
- package/src/__tests__/hub.test.ts +24 -4
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +102 -14
- package/src/__tests__/module-manifest.test.ts +19 -3
- package/src/__tests__/notes-redirect.test.ts +195 -0
- package/src/__tests__/oauth-handlers.test.ts +1101 -13
- package/src/__tests__/oauth-ui.test.ts +31 -1
- package/src/__tests__/origin-check.test.ts +36 -0
- package/src/__tests__/port-assign.test.ts +6 -1
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/rate-limit.test.ts +114 -0
- package/src/__tests__/scope-explanations.test.ts +4 -0
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/serve-boot.test.ts +21 -0
- package/src/__tests__/serve.test.ts +73 -0
- package/src/__tests__/services-manifest.test.ts +760 -43
- package/src/__tests__/setup-wizard.test.ts +267 -15
- package/src/__tests__/setup.test.ts +1 -0
- package/src/__tests__/spawn-env-propagation.test.ts +78 -0
- package/src/__tests__/status.test.ts +30 -14
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/__tests__/well-known.test.ts +223 -3
- package/src/admin-clients.ts +97 -13
- package/src/admin-vaults.ts +6 -1
- package/src/api-account.ts +29 -0
- package/src/api-hub.ts +201 -0
- package/src/api-modules-config.ts +41 -9
- package/src/api-modules-ops.ts +188 -24
- package/src/api-modules.ts +223 -24
- package/src/cli.ts +50 -4
- package/src/clients.ts +25 -2
- package/src/commands/auth.ts +7 -1
- package/src/commands/expose-auth-preflight.ts +6 -1
- package/src/commands/expose-cloudflare.ts +6 -1
- package/src/commands/expose-interactive.ts +7 -1
- package/src/commands/install.ts +181 -12
- package/src/commands/lifecycle.ts +84 -5
- package/src/commands/serve-boot.ts +8 -4
- package/src/commands/serve.ts +54 -9
- package/src/commands/setup.ts +49 -17
- package/src/commands/status.ts +81 -31
- package/src/commands/upgrade.ts +218 -27
- package/src/commands/vault-tokens-create-interactive.ts +7 -1
- package/src/commands/vault.ts +3 -0
- package/src/help.ts +77 -25
- package/src/hub-control.ts +6 -1
- package/src/hub-db.ts +31 -0
- package/src/hub-server.ts +155 -24
- package/src/hub-settings.ts +50 -1
- package/src/hub.ts +85 -14
- package/src/install-source.ts +7 -6
- package/src/module-manifest.ts +22 -17
- package/src/notes-redirect.ts +121 -0
- package/src/oauth-handlers.ts +297 -33
- package/src/oauth-ui.ts +154 -10
- package/src/origin-check.ts +12 -0
- package/src/rate-limit.ts +170 -81
- package/src/scope-explanations.ts +11 -0
- package/src/service-spec.ts +353 -166
- package/src/services-manifest.ts +369 -3
- package/src/setup-wizard.ts +210 -13
- package/src/supervisor.ts +4 -0
- package/src/tailscale/run.ts +7 -1
- package/src/well-known.ts +145 -16
- package/web/ui/dist/assets/index-CGPyOfGK.css +1 -0
- package/web/ui/dist/assets/index-DNTukKZw.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-C2vGcXFG.css +0 -1
- package/web/ui/dist/assets/index-DmUVTI8I.js +0 -61
package/README.md
CHANGED
|
@@ -2,18 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
`@openparachute/hub` — the local hub for the [Parachute](https://parachute.computer) ecosystem. The `parachute` binary is one of its surfaces.
|
|
4
4
|
|
|
5
|
-
The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault,
|
|
5
|
+
The hub coordinates the modules running on your machine: it installs them, runs them as background processes, exposes them over Tailscale, serves the discovery document at `/.well-known/parachute.json`, and (soon) issues OAuth tokens. Each module (vault, app, scribe, …) stays a standalone package; the hub stitches them together.
|
|
6
6
|
|
|
7
7
|
> Previously published as `@openparachute/cli`. Renamed 2026-04-26 to better reflect the role — see [parachute-patterns/hub-as-issuer](https://github.com/ParachuteComputer/parachute-patterns/blob/main/patterns/hub-as-issuer.md). The `parachute` binary name is unchanged.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
### Local (Bun)
|
|
12
|
+
|
|
11
13
|
```sh
|
|
12
14
|
bun add -g @openparachute/hub
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
|
|
16
18
|
|
|
19
|
+
### Hosted (Render)
|
|
20
|
+
|
|
21
|
+
[](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
|
|
22
|
+
|
|
23
|
+
One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
|
|
24
|
+
|
|
25
|
+
**Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
|
|
26
|
+
|
|
27
|
+
After deploy completes:
|
|
28
|
+
|
|
29
|
+
1. Open Render Logs → search for `parachute-bootstrap-` to find your one-time admin setup token (printed in a prominent banner on first boot).
|
|
30
|
+
2. Visit your Render service URL's `/admin/setup` → paste the token → create your admin account.
|
|
31
|
+
3. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match.
|
|
32
|
+
4. Install modules via the admin SPA at `/admin/modules` (or via the wizard).
|
|
33
|
+
|
|
34
|
+
Operators who want env-var-driven seeding (CI, scripted deploys) can still set `PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD` manually in the Render dashboard — hub honors them when present.
|
|
35
|
+
|
|
36
|
+
Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
|
|
37
|
+
|
|
17
38
|
## First 5 minutes
|
|
18
39
|
|
|
19
40
|
```sh
|
|
@@ -160,16 +181,19 @@ Parachute services reserve a block of loopback ports in the canonical range **19
|
|
|
160
181
|
| 1939 | parachute-hub (internal proxy + static) |
|
|
161
182
|
| 1940 | parachute-vault |
|
|
162
183
|
| 1941 | parachute-channel |
|
|
163
|
-
| 1942 | parachute-notes
|
|
184
|
+
| 1942 | parachute-notes *(deprecating — see [notes#154](https://github.com/ParachuteComputer/parachute-notes/issues/154); folds into parachute-app at 1946)* |
|
|
164
185
|
| 1943 | parachute-scribe |
|
|
165
|
-
| 1944
|
|
186
|
+
| 1944 | *parachute-agent (retired 2026-05-20; slot held — see [`parachute-agent/DEPRECATED.md`](https://github.com/ParachuteComputer/parachute-agent/blob/main/DEPRECATED.md))* |
|
|
187
|
+
| 1945 | parachute-runner *(shipped; exploration-tier, not committed-core)* |
|
|
188
|
+
| 1946 | parachute-app *(committed core; UI host, ships Notes as canonical first app)* |
|
|
189
|
+
| 1947–1949 | *unassigned (CLI fallback range)* |
|
|
166
190
|
|
|
167
191
|
The hub pins 1939 — no fallback. If something else is on 1939 when you run `parachute expose`, the command fails with a pointer to `lsof -iTCP:1939` rather than walking up into another service's slot.
|
|
168
192
|
|
|
169
193
|
**The CLI is the port authority.** `parachute install <svc>` picks the port at install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`; lifecycle.start merges that .env into the spawn env so the next daemon boot binds the port the CLI assigned. The algorithm:
|
|
170
194
|
|
|
171
195
|
1. Prefer the canonical slot (e.g. vault → 1940).
|
|
172
|
-
2. On collision, walk the unassigned range (
|
|
196
|
+
2. On collision, walk the unassigned range (1947–1949).
|
|
173
197
|
3. Range exhausted: assign past 1949 with a warning.
|
|
174
198
|
|
|
175
199
|
Idempotent: an existing `PORT=` in `~/.parachute/<svc>/.env` wins, so re-installs and operator-edited ports survive across upgrades. Services keep their compiled-in fallbacks (vault → 1940 etc.) so a stand-alone `bun run` still works without a CLI-managed .env.
|
package/package.json
CHANGED
|
@@ -115,6 +115,32 @@ describe("handleGetClient", () => {
|
|
|
115
115
|
expect(body.redirect_uris).toEqual(["https://app.example/cb"]);
|
|
116
116
|
expect(body.scopes).toEqual(["vault:work:read"]);
|
|
117
117
|
expect(typeof body.registered_at).toBe("string");
|
|
118
|
+
// hub#312 — same_hub surfaced for future SPA badging. Default false
|
|
119
|
+
// when the test registers via the helper (no operator-auth path).
|
|
120
|
+
expect(body.same_hub).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("same_hub=true client surfaces same_hub: true in the response (hub#312)", async () => {
|
|
124
|
+
// The DCR path stamps same_hub=true on operator-authenticated
|
|
125
|
+
// registrations. Pin that the admin view exposes that flag so future
|
|
126
|
+
// SPA changes (per-client same-hub badge) can read it directly from
|
|
127
|
+
// /api/oauth/clients/<id>.
|
|
128
|
+
const { bearer } = await makeOperatorBearer();
|
|
129
|
+
const r = registerClient(harness.db, {
|
|
130
|
+
redirectUris: ["https://app.example/cb"],
|
|
131
|
+
scopes: ["vault:work:read"],
|
|
132
|
+
status: "approved",
|
|
133
|
+
sameHub: true,
|
|
134
|
+
clientName: "SameHubApp",
|
|
135
|
+
});
|
|
136
|
+
const id = r.client.clientId;
|
|
137
|
+
const res = await handleGetClient(getReq(id, bearer), id, {
|
|
138
|
+
db: harness.db,
|
|
139
|
+
issuer: ISSUER,
|
|
140
|
+
});
|
|
141
|
+
expect(res.status).toBe(200);
|
|
142
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
143
|
+
expect(body.same_hub).toBe(true);
|
|
118
144
|
});
|
|
119
145
|
|
|
120
146
|
test("returns the row's status after approval (so the SPA can short-circuit re-approve)", async () => {
|
|
@@ -272,4 +298,165 @@ describe("handleApproveClient", () => {
|
|
|
272
298
|
const res = await handleApproveClient(req, id, { db: harness.db, issuer: ISSUER });
|
|
273
299
|
expect(res.status).toBe(405);
|
|
274
300
|
});
|
|
301
|
+
|
|
302
|
+
// Workstream D — OAuth resume via `return_to`. The SPA approve page
|
|
303
|
+
// can pass a hub-relative authorize URL as JSON body; the response
|
|
304
|
+
// echoes it as `redirect_to` so the SPA can navigate the browser there
|
|
305
|
+
// and resume the parked OAuth flow. The pre-D no-body shape continues
|
|
306
|
+
// to work (no `redirect_to` field, share-link dead-end case).
|
|
307
|
+
describe("workstream D — return_to / redirect_to", () => {
|
|
308
|
+
function jsonApproveReq(clientId: string, bearer: string, body: unknown): Request {
|
|
309
|
+
return new Request(`${ISSUER}/api/oauth/clients/${encodeURIComponent(clientId)}/approve`, {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: {
|
|
312
|
+
authorization: `Bearer ${bearer}`,
|
|
313
|
+
"content-type": "application/json",
|
|
314
|
+
},
|
|
315
|
+
body: JSON.stringify(body),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
test("echoes a same-origin /oauth/authorize?... return_to as redirect_to", async () => {
|
|
320
|
+
const { bearer } = await makeOperatorBearer();
|
|
321
|
+
const id = regPending();
|
|
322
|
+
const returnTo =
|
|
323
|
+
"/oauth/authorize?client_id=" +
|
|
324
|
+
encodeURIComponent(id) +
|
|
325
|
+
"&response_type=code&scope=vault%3Awork%3Aread";
|
|
326
|
+
const res = await handleApproveClient(
|
|
327
|
+
jsonApproveReq(id, bearer, { return_to: returnTo }),
|
|
328
|
+
id,
|
|
329
|
+
{ db: harness.db, issuer: ISSUER },
|
|
330
|
+
);
|
|
331
|
+
expect(res.status).toBe(200);
|
|
332
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
333
|
+
expect(body.redirect_to).toBe(returnTo);
|
|
334
|
+
expect(body.status).toBe("approved");
|
|
335
|
+
expect(getClient(harness.db, id)?.status).toBe("approved");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("omits redirect_to entirely when return_to is missing (share-link case preserved)", async () => {
|
|
339
|
+
const { bearer } = await makeOperatorBearer();
|
|
340
|
+
const id = regPending();
|
|
341
|
+
// No body — the pre-D shape. The endpoint must continue to work.
|
|
342
|
+
const res = await handleApproveClient(approveReq(id, bearer), id, {
|
|
343
|
+
db: harness.db,
|
|
344
|
+
issuer: ISSUER,
|
|
345
|
+
});
|
|
346
|
+
expect(res.status).toBe(200);
|
|
347
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
348
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
349
|
+
expect(body.status).toBe("approved");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("drops an off-origin return_to (scheme-relative) silently, still approves", async () => {
|
|
353
|
+
const { bearer } = await makeOperatorBearer();
|
|
354
|
+
const id = regPending();
|
|
355
|
+
const res = await handleApproveClient(
|
|
356
|
+
jsonApproveReq(id, bearer, { return_to: "//evil.example/oauth/authorize?foo=1" }),
|
|
357
|
+
id,
|
|
358
|
+
{ db: harness.db, issuer: ISSUER },
|
|
359
|
+
);
|
|
360
|
+
expect(res.status).toBe(200);
|
|
361
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
362
|
+
// No redirect_to — server refuses to echo a bad value. The client
|
|
363
|
+
// is still approved (we don't fail an otherwise-legitimate approve
|
|
364
|
+
// over a malformed return_to).
|
|
365
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
366
|
+
expect(getClient(harness.db, id)?.status).toBe("approved");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("drops a non-authorize return_to (off-path) silently", async () => {
|
|
370
|
+
const { bearer } = await makeOperatorBearer();
|
|
371
|
+
const id = regPending();
|
|
372
|
+
const res = await handleApproveClient(
|
|
373
|
+
jsonApproveReq(id, bearer, { return_to: "/admin/vaults" }),
|
|
374
|
+
id,
|
|
375
|
+
{ db: harness.db, issuer: ISSUER },
|
|
376
|
+
);
|
|
377
|
+
expect(res.status).toBe(200);
|
|
378
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
379
|
+
// `/admin/vaults` is same-origin but isn't a `/oauth/authorize?...`
|
|
380
|
+
// URL — the server-side gate is "authorize URL only" so the SPA
|
|
381
|
+
// can't be used as a redirect gadget for arbitrary in-SPA navigation.
|
|
382
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("drops absolute URL return_to silently", async () => {
|
|
386
|
+
const { bearer } = await makeOperatorBearer();
|
|
387
|
+
const id = regPending();
|
|
388
|
+
const res = await handleApproveClient(
|
|
389
|
+
jsonApproveReq(id, bearer, {
|
|
390
|
+
return_to: "https://evil.example/oauth/authorize?foo=1",
|
|
391
|
+
}),
|
|
392
|
+
id,
|
|
393
|
+
{ db: harness.db, issuer: ISSUER },
|
|
394
|
+
);
|
|
395
|
+
expect(res.status).toBe(200);
|
|
396
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
397
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("non-JSON body is treated as 'no return_to' (no parser explosion)", async () => {
|
|
401
|
+
const { bearer } = await makeOperatorBearer();
|
|
402
|
+
const id = regPending();
|
|
403
|
+
// text/plain body — pre-D / unknown clients send anything. The
|
|
404
|
+
// endpoint must NOT throw on parse and must NOT echo a redirect_to.
|
|
405
|
+
const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
authorization: `Bearer ${bearer}`,
|
|
409
|
+
"content-type": "text/plain",
|
|
410
|
+
},
|
|
411
|
+
body: "garbage",
|
|
412
|
+
});
|
|
413
|
+
const res = await handleApproveClient(req, id, {
|
|
414
|
+
db: harness.db,
|
|
415
|
+
issuer: ISSUER,
|
|
416
|
+
});
|
|
417
|
+
expect(res.status).toBe(200);
|
|
418
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
419
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("malformed JSON body is treated as 'no return_to'", async () => {
|
|
423
|
+
const { bearer } = await makeOperatorBearer();
|
|
424
|
+
const id = regPending();
|
|
425
|
+
const req = new Request(`${ISSUER}/api/oauth/clients/${id}/approve`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: {
|
|
428
|
+
authorization: `Bearer ${bearer}`,
|
|
429
|
+
"content-type": "application/json",
|
|
430
|
+
},
|
|
431
|
+
body: "{not json",
|
|
432
|
+
});
|
|
433
|
+
const res = await handleApproveClient(req, id, {
|
|
434
|
+
db: harness.db,
|
|
435
|
+
issuer: ISSUER,
|
|
436
|
+
});
|
|
437
|
+
expect(res.status).toBe(200);
|
|
438
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
439
|
+
expect(body).not.toHaveProperty("redirect_to");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("re-approve with return_to echoes redirect_to (idempotent path)", async () => {
|
|
443
|
+
// The OAuth resume flow can legitimately race: operator opens the
|
|
444
|
+
// approve link, an automated path approves the same client, then
|
|
445
|
+
// operator clicks. We still want the redirect to fire so the
|
|
446
|
+
// operator's flow resumes — not dead-end on already_approved.
|
|
447
|
+
const { bearer } = await makeOperatorBearer();
|
|
448
|
+
const id = regPending();
|
|
449
|
+
approveClient(harness.db, id);
|
|
450
|
+
const returnTo = `/oauth/authorize?client_id=${encodeURIComponent(id)}&response_type=code&scope=vault%3Awork%3Aread`;
|
|
451
|
+
const res = await handleApproveClient(
|
|
452
|
+
jsonApproveReq(id, bearer, { return_to: returnTo }),
|
|
453
|
+
id,
|
|
454
|
+
{ db: harness.db, issuer: ISSUER },
|
|
455
|
+
);
|
|
456
|
+
expect(res.status).toBe(200);
|
|
457
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
458
|
+
expect(body.already_approved).toBe(true);
|
|
459
|
+
expect(body.redirect_to).toBe(returnTo);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
275
462
|
});
|
|
@@ -29,6 +29,11 @@ import {
|
|
|
29
29
|
} from "../api-account.ts";
|
|
30
30
|
import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
|
|
31
31
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
32
|
+
import {
|
|
33
|
+
CHANGE_PASSWORD_MAX_ATTEMPTS,
|
|
34
|
+
CHANGE_PASSWORD_WINDOW_MS,
|
|
35
|
+
changePasswordRateLimiter,
|
|
36
|
+
} from "../rate-limit.ts";
|
|
32
37
|
import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
|
|
33
38
|
import { createUser, getUserById, verifyPassword } from "../users.ts";
|
|
34
39
|
|
|
@@ -84,6 +89,13 @@ function formBody(values: Record<string, string>): {
|
|
|
84
89
|
let harness: Harness;
|
|
85
90
|
beforeEach(() => {
|
|
86
91
|
harness = makeHarness();
|
|
92
|
+
// Per-test rate-limit reset — change-password tests share the
|
|
93
|
+
// singleton `changePasswordRateLimiter`, and a test that exhausts a
|
|
94
|
+
// user-id bucket would 429-cascade into the next test if the user-id
|
|
95
|
+
// happened to collide. Per-harness DB → fresh user-ids, so in practice
|
|
96
|
+
// there's no collision, but the explicit reset matches `admin-handlers`
|
|
97
|
+
// discipline and pins the contract.
|
|
98
|
+
changePasswordRateLimiter.reset();
|
|
87
99
|
});
|
|
88
100
|
afterEach(() => {
|
|
89
101
|
harness.cleanup();
|
|
@@ -438,6 +450,161 @@ describe("POST /account/change-password", () => {
|
|
|
438
450
|
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
439
451
|
expect(setCookie).not.toContain("Max-Age=0");
|
|
440
452
|
});
|
|
453
|
+
|
|
454
|
+
// hub#282 — per-user rate-limit on /account/change-password.
|
|
455
|
+
// CHANGE_PASSWORD_MAX_ATTEMPTS attempts per CHANGE_PASSWORD_WINDOW_MS;
|
|
456
|
+
// (CHANGE_PASSWORD_MAX_ATTEMPTS+1)th attempt within the window is 429.
|
|
457
|
+
test("rapid wrong-current_password attempts exhaust the bucket and 429 with Retry-After", async () => {
|
|
458
|
+
const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
|
|
459
|
+
passwordChanged: false,
|
|
460
|
+
});
|
|
461
|
+
const buildReq = () => {
|
|
462
|
+
const { body, headers } = formBody({
|
|
463
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
464
|
+
current_password: "this-is-wrong",
|
|
465
|
+
new_password: "long-enough-passphrase",
|
|
466
|
+
new_password_confirm: "long-enough-passphrase",
|
|
467
|
+
});
|
|
468
|
+
return new Request("http://hub.test/account/change-password", {
|
|
469
|
+
method: "POST",
|
|
470
|
+
headers: { ...headers, cookie },
|
|
471
|
+
body,
|
|
472
|
+
});
|
|
473
|
+
};
|
|
474
|
+
// First N attempts: wrong current → 401 each (admitted by rate limiter,
|
|
475
|
+
// failed by argon2id verify).
|
|
476
|
+
for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
|
|
477
|
+
const r = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
|
|
478
|
+
expect(r.status).toBe(401);
|
|
479
|
+
}
|
|
480
|
+
// (N+1)th attempt: rate-limit fires before argon2id → 429 + Retry-After.
|
|
481
|
+
const denied = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
|
|
482
|
+
expect(denied.status).toBe(429);
|
|
483
|
+
const retryAfter = denied.headers.get("retry-after");
|
|
484
|
+
expect(retryAfter).not.toBeNull();
|
|
485
|
+
const seconds = Number(retryAfter);
|
|
486
|
+
expect(seconds).toBeGreaterThan(0);
|
|
487
|
+
// Window is CHANGE_PASSWORD_WINDOW_MS, so retry-after sits in (0, window].
|
|
488
|
+
expect(seconds).toBeLessThanOrEqual(CHANGE_PASSWORD_WINDOW_MS / 1000);
|
|
489
|
+
// Body should re-render the form with the rate-limit message.
|
|
490
|
+
const html = await denied.text();
|
|
491
|
+
expect(html).toContain("Too many password-change attempts");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("rate-limit is per-user: two users have independent buckets", async () => {
|
|
495
|
+
const userA = await sessionCookieFor(harness.db, "user-a", "pw-a", {
|
|
496
|
+
passwordChanged: false,
|
|
497
|
+
});
|
|
498
|
+
const userB = await sessionCookieFor(harness.db, "user-b", "pw-b", {
|
|
499
|
+
passwordChanged: false,
|
|
500
|
+
allowMulti: true,
|
|
501
|
+
});
|
|
502
|
+
const buildReq = (cookie: string) => {
|
|
503
|
+
const { body, headers } = formBody({
|
|
504
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
505
|
+
current_password: "wrong",
|
|
506
|
+
new_password: "long-enough-passphrase",
|
|
507
|
+
new_password_confirm: "long-enough-passphrase",
|
|
508
|
+
});
|
|
509
|
+
return new Request("http://hub.test/account/change-password", {
|
|
510
|
+
method: "POST",
|
|
511
|
+
headers: { ...headers, cookie },
|
|
512
|
+
body,
|
|
513
|
+
});
|
|
514
|
+
};
|
|
515
|
+
// Exhaust user-a's bucket.
|
|
516
|
+
for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
|
|
517
|
+
await handleAccountChangePasswordPost(buildReq(userA.cookie), { db: harness.db });
|
|
518
|
+
}
|
|
519
|
+
const aDenied = await handleAccountChangePasswordPost(buildReq(userA.cookie), {
|
|
520
|
+
db: harness.db,
|
|
521
|
+
});
|
|
522
|
+
expect(aDenied.status).toBe(429);
|
|
523
|
+
// user-b's bucket is untouched — first attempt should be admitted
|
|
524
|
+
// (and reject for wrong current → 401, not 429).
|
|
525
|
+
const bAttempt = await handleAccountChangePasswordPost(buildReq(userB.cookie), {
|
|
526
|
+
db: harness.db,
|
|
527
|
+
});
|
|
528
|
+
expect(bAttempt.status).toBe(401);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("rate-limit gate fires before argon2id verify (denied request is fast)", async () => {
|
|
532
|
+
// Pin the "fires before verifyPassword" property with an elapsed-time
|
|
533
|
+
// floor on the 429 response — argon2id verify would push elapsed
|
|
534
|
+
// into the hundreds of ms; the 429 path skips it.
|
|
535
|
+
const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
|
|
536
|
+
passwordChanged: false,
|
|
537
|
+
});
|
|
538
|
+
const buildReq = () => {
|
|
539
|
+
const { body, headers } = formBody({
|
|
540
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
541
|
+
current_password: "wrong",
|
|
542
|
+
new_password: "long-enough-passphrase",
|
|
543
|
+
new_password_confirm: "long-enough-passphrase",
|
|
544
|
+
});
|
|
545
|
+
return new Request("http://hub.test/account/change-password", {
|
|
546
|
+
method: "POST",
|
|
547
|
+
headers: { ...headers, cookie },
|
|
548
|
+
body,
|
|
549
|
+
});
|
|
550
|
+
};
|
|
551
|
+
// Fill the bucket.
|
|
552
|
+
for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS; i++) {
|
|
553
|
+
await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
|
|
554
|
+
}
|
|
555
|
+
// The (N+1)th attempt should 429-and-return without touching argon2id.
|
|
556
|
+
const t0 = Date.now();
|
|
557
|
+
const denied = await handleAccountChangePasswordPost(buildReq(), { db: harness.db });
|
|
558
|
+
const elapsed = Date.now() - t0;
|
|
559
|
+
expect(denied.status).toBe(429);
|
|
560
|
+
// 200ms is enough headroom even on a noisy runner; an argon2id verify
|
|
561
|
+
// would push elapsed into the hundreds of ms.
|
|
562
|
+
expect(elapsed).toBeLessThan(200);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("CSRF failure does NOT burn a rate-limit slot", async () => {
|
|
566
|
+
// Gate-order invariant: rate-limit fires *after* CSRF, so a junk
|
|
567
|
+
// cross-site POST (which would never have a valid CSRF token) doesn't
|
|
568
|
+
// burn a bucket slot for the victim's session. Pin by sending
|
|
569
|
+
// (max+1) CSRF-broken requests and then confirming a fresh, valid
|
|
570
|
+
// attempt is admitted (would-be 401 for wrong current_password, not
|
|
571
|
+
// 429).
|
|
572
|
+
const { cookie } = await sessionCookieFor(harness.db, "newbie", "correct-pw", {
|
|
573
|
+
passwordChanged: false,
|
|
574
|
+
});
|
|
575
|
+
const csrfBroken = () => {
|
|
576
|
+
const { body, headers } = formBody({
|
|
577
|
+
[CSRF_FIELD_NAME]: "wrong-token",
|
|
578
|
+
current_password: "wrong",
|
|
579
|
+
new_password: "long-enough-passphrase",
|
|
580
|
+
new_password_confirm: "long-enough-passphrase",
|
|
581
|
+
});
|
|
582
|
+
return new Request("http://hub.test/account/change-password", {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: { ...headers, cookie },
|
|
585
|
+
body,
|
|
586
|
+
});
|
|
587
|
+
};
|
|
588
|
+
for (let i = 0; i < CHANGE_PASSWORD_MAX_ATTEMPTS + 2; i++) {
|
|
589
|
+
const r = await handleAccountChangePasswordPost(csrfBroken(), { db: harness.db });
|
|
590
|
+
expect(r.status).toBe(400);
|
|
591
|
+
}
|
|
592
|
+
// Now send a CSRF-valid attempt — should NOT be 429 (CSRF-broken
|
|
593
|
+
// attempts never reached the rate limiter).
|
|
594
|
+
const { body, headers } = formBody({
|
|
595
|
+
[CSRF_FIELD_NAME]: TEST_CSRF,
|
|
596
|
+
current_password: "wrong",
|
|
597
|
+
new_password: "long-enough-passphrase",
|
|
598
|
+
new_password_confirm: "long-enough-passphrase",
|
|
599
|
+
});
|
|
600
|
+
const valid = new Request("http://hub.test/account/change-password", {
|
|
601
|
+
method: "POST",
|
|
602
|
+
headers: { ...headers, cookie },
|
|
603
|
+
body,
|
|
604
|
+
});
|
|
605
|
+
const res = await handleAccountChangePasswordPost(valid, { db: harness.db });
|
|
606
|
+
expect(res.status).toBe(401);
|
|
607
|
+
});
|
|
441
608
|
});
|
|
442
609
|
|
|
443
610
|
describe("markPasswordChanged", () => {
|