@openparachute/hub 0.5.12-rc.2 → 0.5.13-rc.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +26 -0
- package/src/__tests__/api-account.test.ts +167 -0
- package/src/__tests__/api-modules-config.test.ts +876 -0
- package/src/__tests__/api-modules.test.ts +192 -4
- package/src/__tests__/hub-control.test.ts +147 -4
- package/src/__tests__/hub-server.test.ts +221 -41
- package/src/__tests__/hub-settings.test.ts +65 -0
- package/src/__tests__/notes-redirect.test.ts +195 -0
- package/src/__tests__/oauth-handlers.test.ts +1066 -0
- package/src/__tests__/post-install.test.ts +294 -0
- package/src/__tests__/rate-limit.test.ts +114 -0
- package/src/__tests__/scope-explanations.test.ts +4 -0
- package/src/__tests__/services-manifest.test.ts +231 -0
- package/src/__tests__/setup.test.ts +1 -0
- package/src/__tests__/well-known.test.ts +180 -0
- package/src/admin-clients.ts +8 -0
- package/src/api-account.ts +29 -0
- package/src/api-modules-config.ts +420 -0
- package/src/api-modules-ops.ts +141 -92
- package/src/api-modules.ts +129 -26
- package/src/clients.ts +25 -2
- package/src/commands/install.ts +128 -12
- package/src/commands/lifecycle.ts +47 -3
- package/src/commands/setup.ts +46 -16
- package/src/hub-control.ts +104 -5
- package/src/hub-db.ts +31 -0
- package/src/hub-server.ts +84 -13
- package/src/hub-settings.ts +50 -1
- package/src/install-source.ts +7 -6
- package/src/notes-redirect.ts +121 -0
- package/src/oauth-handlers.ts +237 -20
- package/src/oauth-ui.ts +88 -2
- package/src/post-install.ts +130 -0
- package/src/rate-limit.ts +170 -81
- package/src/scope-explanations.ts +11 -0
- package/src/service-spec.ts +318 -100
- package/src/services-manifest.ts +168 -0
- package/src/well-known.ts +94 -1
- package/web/ui/dist/assets/index-D63mUkVX.js +61 -0
- package/web/ui/dist/assets/{index-p6DkOcsk.css → index-DliViliP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BKFoB4gE.js +0 -61
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 () => {
|
|
@@ -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", () => {
|