@openparachute/hub 0.7.4-rc.9 → 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 +207 -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/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 +14 -1
- 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/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/src/admin-handlers.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
|
|
12
12
|
*/
|
|
13
13
|
import type { Database } from "bun:sqlite";
|
|
14
|
+
import { recordLoginUnlock } from "./admin-lock.ts";
|
|
14
15
|
import { renderAdminError, renderAdminLogin, renderTotpChallenge } from "./admin-login-ui.ts";
|
|
15
16
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
16
17
|
import {
|
|
@@ -283,6 +284,7 @@ function mintSessionAndRedirect(
|
|
|
283
284
|
extraCookies: string[] = [],
|
|
284
285
|
): Response {
|
|
285
286
|
const session = createSession(db, { userId: user.id });
|
|
287
|
+
recordLoginUnlock(db, session.id);
|
|
286
288
|
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
287
289
|
secure: isHttpsRequest(req),
|
|
288
290
|
});
|
|
@@ -49,7 +49,14 @@
|
|
|
49
49
|
import type { Database } from "bun:sqlite";
|
|
50
50
|
import { lockedResponse, requireUnlocked } from "./admin-lock.ts";
|
|
51
51
|
import { signAccessToken } from "./jwt-sign.ts";
|
|
52
|
-
import {
|
|
52
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
53
|
+
import {
|
|
54
|
+
SESSION_TTL_MS,
|
|
55
|
+
buildSessionCookie,
|
|
56
|
+
findSession,
|
|
57
|
+
parseSessionCookie,
|
|
58
|
+
touchSession,
|
|
59
|
+
} from "./sessions.ts";
|
|
53
60
|
import { isFirstAdmin } from "./users.ts";
|
|
54
61
|
|
|
55
62
|
/** Short TTL — page-snapshot threats can't carry the token forever. */
|
|
@@ -115,6 +122,21 @@ export async function handleHostAdminToken(
|
|
|
115
122
|
// sentinel matching admin OAuth tokens.
|
|
116
123
|
vaultScope: [],
|
|
117
124
|
});
|
|
125
|
+
// Sliding session renewal (THE frequent-re-login fix). The SPA re-mints here
|
|
126
|
+
// roughly every ~10 min while a tab is open; each successful mint pushes the
|
|
127
|
+
// session's `expires_at` forward, so an active operator isn't hard-logged-out
|
|
128
|
+
// at the 24h mark. A closed tab stops minting and still expires; the absolute
|
|
129
|
+
// ceiling in `touchSession` bounds a left-open-but-idle tab. The renewed
|
|
130
|
+
// Set-Cookie keeps the EXACT attributes session creation uses — HttpOnly,
|
|
131
|
+
// Secure-when-https, SameSite=Lax, Path=/, host-only (no Domain) — so the
|
|
132
|
+
// cookie's Max-Age tracks the extended expiry without broadening the cookie.
|
|
133
|
+
// This does NOT touch the admin-lock idle window (sliding there is
|
|
134
|
+
// heartbeat-only, by design — see admin-lock.ts); the two windows are
|
|
135
|
+
// independent.
|
|
136
|
+
touchSession(deps.db, sid);
|
|
137
|
+
const sessionCookie = buildSessionCookie(sid, Math.floor(SESSION_TTL_MS / 1000), {
|
|
138
|
+
secure: isHttpsRequest(req),
|
|
139
|
+
});
|
|
118
140
|
return new Response(
|
|
119
141
|
JSON.stringify({
|
|
120
142
|
token: minted.token,
|
|
@@ -128,6 +150,7 @@ export async function handleHostAdminToken(
|
|
|
128
150
|
// No browser cache — token rotates per-fetch, and a stale 200 from a
|
|
129
151
|
// back/forward navigation could hand the SPA a long-expired JWT.
|
|
130
152
|
"cache-control": "no-store",
|
|
153
|
+
"set-cookie": sessionCookie,
|
|
131
154
|
},
|
|
132
155
|
},
|
|
133
156
|
);
|
package/src/admin-lock.ts
CHANGED
|
@@ -174,6 +174,22 @@ export function recordUnlock(
|
|
|
174
174
|
unlockedUntil.set(sessionId, now + idleSeconds * 1000);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Open an unlock window for a session that JUST completed a full auth
|
|
179
|
+
* (password + any 2FA). The PIN's threat model is the idle/grabbed tab, NOT a
|
|
180
|
+
* third gate the instant after the auth boundary — re-prompting for the PIN
|
|
181
|
+
* the moment after a successful login is pure friction with no security gain
|
|
182
|
+
* (the user just proved a stronger factor). No-op when the lock feature is off.
|
|
183
|
+
*
|
|
184
|
+
* Called from every session-mint point (password login, OAuth login,
|
|
185
|
+
* account-setup, setup-wizard) so a freshly-authenticated session always lands
|
|
186
|
+
* working, never on the lock screen. Idle re-entry still re-locks as before.
|
|
187
|
+
*/
|
|
188
|
+
export function recordLoginUnlock(db: Database, sessionId: string, now: number = Date.now()): void {
|
|
189
|
+
if (!isLockConfigured(db)) return;
|
|
190
|
+
recordUnlock(sessionId, getIdleSeconds(db), now);
|
|
191
|
+
}
|
|
192
|
+
|
|
177
193
|
/**
|
|
178
194
|
* Slide the unlock window forward by `idleSeconds` IF the session is currently
|
|
179
195
|
* unlocked. Called on admin activity (heartbeat + every successful mint) so an
|
package/src/admin-vaults.ts
CHANGED
|
@@ -129,6 +129,15 @@ export interface CreateVaultDeps {
|
|
|
129
129
|
db: Database;
|
|
130
130
|
/** Hub origin used to validate JWT `iss` and to build the response `url`. */
|
|
131
131
|
issuer: string;
|
|
132
|
+
/**
|
|
133
|
+
* SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
|
|
134
|
+
* platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
|
|
135
|
+
* admin bearer's `iss` is validated against THIS set rather than the single
|
|
136
|
+
* `issuer`, so a host-admin credential minted under a still-valid prior
|
|
137
|
+
* origin keeps working across an origin switch (hub#516 parity). Absent →
|
|
138
|
+
* falls back to `[issuer]` (the prior strict per-request behavior).
|
|
139
|
+
*/
|
|
140
|
+
knownIssuers?: readonly string[];
|
|
132
141
|
/** Override the services.json path. Defaults to `~/.parachute/services.json`. */
|
|
133
142
|
manifestPath?: string;
|
|
134
143
|
/**
|
|
@@ -442,7 +451,7 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
442
451
|
// Auth gate: parachute:host:admin scope. Maps an AdminAuthError straight
|
|
443
452
|
// to an RFC 6750 401/403 — the route handler doesn't care which.
|
|
444
453
|
try {
|
|
445
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
454
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
446
455
|
} catch (err) {
|
|
447
456
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
448
457
|
}
|
|
@@ -530,6 +539,15 @@ export interface DeleteVaultDeps {
|
|
|
530
539
|
db: Database;
|
|
531
540
|
/** Hub origin — JWT `iss` validation + cascade mint issuer. */
|
|
532
541
|
issuer: string;
|
|
542
|
+
/**
|
|
543
|
+
* SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
|
|
544
|
+
* platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
|
|
545
|
+
* admin bearer's `iss` is validated against THIS set rather than the single
|
|
546
|
+
* `issuer`, so a host-admin credential minted under a still-valid prior
|
|
547
|
+
* origin keeps working across an origin switch (hub#516 parity). Absent →
|
|
548
|
+
* falls back to `[issuer]` (the prior strict per-request behavior).
|
|
549
|
+
*/
|
|
550
|
+
knownIssuers?: readonly string[];
|
|
533
551
|
/** Override the services.json path. Defaults to `~/.parachute/services.json`. */
|
|
534
552
|
manifestPath?: string;
|
|
535
553
|
/** Absolute path to `connections.json` in the hub state dir. */
|
|
@@ -630,15 +648,22 @@ export function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
630
648
|
*
|
|
631
649
|
* Refusals:
|
|
632
650
|
* - unknown vault → 404;
|
|
633
|
-
* - LAST remaining vault → 409. Vault's boot auto-creates `default` at
|
|
634
|
-
* zero vaults, so deleting the last one would silently resurrect a fresh
|
|
635
|
-
* `default` (with a fresh global API key) — refusing sidesteps the
|
|
636
|
-
* resurrection class entirely. The CLI (`parachute-vault remove`) is the
|
|
637
|
-
* escape hatch for an operator who really means it.
|
|
638
651
|
* - RESERVED names are deliberately ALLOWED (no reserved-name gate): a
|
|
639
652
|
* squatted `admin`/`new`/`assets` vault created before the B2h
|
|
640
653
|
* reservation must be removable through this endpoint.
|
|
641
654
|
*
|
|
655
|
+
* Last-vault handling (#678): deleting the LAST remaining vault runs the SAME
|
|
656
|
+
* cascade-then-delete as any other vault — it is NOT refused. The old 409 that
|
|
657
|
+
* steered the operator to the raw `parachute-vault remove` CLI was a
|
|
658
|
+
* correctness defect: that escape hatch SKIPS this cascade, orphaning tokens +
|
|
659
|
+
* grants that named the last vault. The resurrection risk the refusal once
|
|
660
|
+
* guarded (vault boot auto-creating a fresh-credentialed first vault at zero
|
|
661
|
+
* vaults) is handled downstream instead: the vault CLI writes an
|
|
662
|
+
* `auto_create: false` marker on last-vault removal and the vault boot gate
|
|
663
|
+
* honors it, so the server won't silently resurrect. Detection stays
|
|
664
|
+
* count-based + name-agnostic (no `name === "default"` special case); the last
|
|
665
|
+
* vault just adds a `last_vault` warning to the 200 response.
|
|
666
|
+
*
|
|
642
667
|
* Cascade, in order (identity first, mechanics last — revocation is the safe
|
|
643
668
|
* direction if a later step fails):
|
|
644
669
|
* 1. tokens-registry sweep (exact scope-segment match — never SQL LIKE);
|
|
@@ -661,6 +686,15 @@ export function listVaultInstanceNames(manifestPath: string): Set<string> {
|
|
|
661
686
|
* Response: 200 with a structured per-step summary (counts +
|
|
662
687
|
* `orphaned_channels` + warnings). A mechanics failure responds 500 with
|
|
663
688
|
* the partial summary — the identity artifacts already revoked stay revoked.
|
|
689
|
+
*
|
|
690
|
+
* Bounded residual: the cascade revokes every *registered* token row naming
|
|
691
|
+
* the vault, but an UNREGISTERED interactive-mint (a host-admin browser
|
|
692
|
+
* session minting a short-lived vault token at ≤10-min TTL — see
|
|
693
|
+
* REGISTERED_MINT_TTL_THRESHOLD_SECONDS in admin-connections.ts) that was
|
|
694
|
+
* issued just before the delete leaves no registry row to sweep. Such a token
|
|
695
|
+
* stays valid for at most its remaining ≤10-min TTL, against a vault whose
|
|
696
|
+
* daemon is evicted in step 7 anyway. Same bound the auth-codes note below
|
|
697
|
+
* relies on; not eliminated, just bounded.
|
|
664
698
|
*/
|
|
665
699
|
export async function handleDeleteVault(
|
|
666
700
|
req: Request,
|
|
@@ -677,7 +711,12 @@ export async function handleDeleteVault(
|
|
|
677
711
|
// Auth gate: parachute:host:admin — the same gate as POST /vaults.
|
|
678
712
|
let adminSub: string;
|
|
679
713
|
try {
|
|
680
|
-
const auth = await requireScope(
|
|
714
|
+
const auth = await requireScope(
|
|
715
|
+
deps.db,
|
|
716
|
+
req,
|
|
717
|
+
HOST_ADMIN_SCOPE,
|
|
718
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
719
|
+
);
|
|
681
720
|
adminSub = auth.sub;
|
|
682
721
|
} catch (err) {
|
|
683
722
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
@@ -714,16 +753,20 @@ export async function handleDeleteVault(
|
|
|
714
753
|
return jsonError(404, "not_found", `no vault named "${name}" on this hub`);
|
|
715
754
|
}
|
|
716
755
|
|
|
717
|
-
// Last-vault
|
|
756
|
+
// Last-vault detection (count-based, name-agnostic). Deleting the LAST
|
|
757
|
+
// vault used to refuse with 409 and steer the operator to the raw
|
|
758
|
+
// `parachute-vault remove` CLI — but that path SKIPS this whole identity
|
|
759
|
+
// cascade, orphaning tokens + grants that named the vault. The resurrection
|
|
760
|
+
// risk the refusal guarded against is already handled downstream: the vault
|
|
761
|
+
// CLI writes an `auto_create: false` marker when it removes the last vault
|
|
762
|
+
// (vault `cli.ts` cmdRemove) and the vault boot gate honors it
|
|
763
|
+
// (`bootAutoCreateAllowed` in vault `config.ts`), so the server won't
|
|
764
|
+
// silently resurrect a fresh-credentialed first vault. We therefore run the
|
|
765
|
+
// cascade-then-delete for the last vault exactly as for any other — the
|
|
766
|
+
// count is informational only (no refusal).
|
|
718
767
|
const instanceNames = listVaultInstanceNames(manifestPath);
|
|
719
768
|
instanceNames.delete(name);
|
|
720
|
-
|
|
721
|
-
return jsonError(
|
|
722
|
-
409,
|
|
723
|
-
"last_vault",
|
|
724
|
-
`"${name}" is the last vault on this hub. Vault's boot auto-creates "default" at zero vaults, so deleting the last one would silently resurrect it with fresh credentials. Create another vault first, or use the CLI (parachute-vault remove ${name} --yes) if you really mean to empty the hub.`,
|
|
725
|
-
);
|
|
726
|
-
}
|
|
769
|
+
const isLastVault = instanceNames.size === 0;
|
|
727
770
|
|
|
728
771
|
const summary = emptyCascadeSummary();
|
|
729
772
|
const warnings: { step: string; detail: string }[] = [];
|
|
@@ -865,6 +908,18 @@ export async function handleDeleteVault(
|
|
|
865
908
|
);
|
|
866
909
|
}
|
|
867
910
|
|
|
911
|
+
// Last-vault heads-up. The vault CLI's remove wrote `auto_create: false`, so
|
|
912
|
+
// the next vault boot won't resurrect a fresh-credentialed first vault — the
|
|
913
|
+
// hub is now deliberately empty. Surface that so the operator knows to create
|
|
914
|
+
// one when they want the hub serving again.
|
|
915
|
+
if (isLastVault) {
|
|
916
|
+
warnings.push({
|
|
917
|
+
step: "last_vault",
|
|
918
|
+
detail:
|
|
919
|
+
"the deleted vault was the last one on this hub — no vaults remain. The vault CLI wrote auto_create: false, so boot won't recreate a default vault. Create one with: parachute-vault create <name>",
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
868
923
|
// --- 7. Daemon eviction: supervisor-restart the vault module. -------------
|
|
869
924
|
if (deps.restartVaultModule) {
|
|
870
925
|
try {
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/account/*` — JSON self-service account surfaces for the admin SPA
|
|
3
|
+
* (hub#85). The server-rendered `/account/2fa` + `/account/change-password`
|
|
4
|
+
* pages stay (they work without JS, the friend-facing path); these are the
|
|
5
|
+
* JSON twins the in-`/admin` SPA "My account" page drives.
|
|
6
|
+
*
|
|
7
|
+
* POST /api/account/2fa/start → mint a fresh secret + QR + otpauth URL
|
|
8
|
+
* (NOT persisted — confirm seals it)
|
|
9
|
+
* POST /api/account/2fa/confirm → verify a live code vs the in-flight
|
|
10
|
+
* secret, persist enrollment, return the
|
|
11
|
+
* backup codes ONCE
|
|
12
|
+
* POST /api/account/2fa/disable → verify current password, clear 2FA
|
|
13
|
+
* POST /api/account/password → verify current, set new (+ revoke the
|
|
14
|
+
* user's still-active tokens)
|
|
15
|
+
*
|
|
16
|
+
* Auth posture: every endpoint is **self-service** — it acts on the
|
|
17
|
+
* SIGNED-IN user's OWN account (`session.userId`), never a client-supplied
|
|
18
|
+
* user id. ANY authenticated user reaches them (the owner / first-admin is
|
|
19
|
+
* NOT special — same path, no privilege bypass). This is deliberately the
|
|
20
|
+
* `/api/admin-lock` cookie+CSRF posture, NOT the host-admin Bearer posture:
|
|
21
|
+
* a user managing their own credentials shouldn't need (or have) the
|
|
22
|
+
* `parachute:host:admin` scope. Order on every POST:
|
|
23
|
+
*
|
|
24
|
+
* 1. Session cookie (else 401).
|
|
25
|
+
* 2. CSRF double-submit `__csrf` in the JSON body (else 403). Same-origin
|
|
26
|
+
* belt is applied by the hub-server dispatcher before this runs.
|
|
27
|
+
* 3. Per-action validation.
|
|
28
|
+
*
|
|
29
|
+
* The crypto + persistence is REUSED, never duplicated: secret generation +
|
|
30
|
+
* code verification live in `totp.ts`; enrollment storage lives in
|
|
31
|
+
* `two-factor-store.ts`; password validation + hashing live in `users.ts`.
|
|
32
|
+
* This file is the JSON wire layer only.
|
|
33
|
+
*
|
|
34
|
+
* In-flight-secret model (mirrors the server-rendered flow): `start` returns
|
|
35
|
+
* the secret, the SPA holds it client-side, and `confirm` sends it back with
|
|
36
|
+
* the live code. Nothing is persisted until `confirm` verifies — an abandoned
|
|
37
|
+
* setup leaves zero state.
|
|
38
|
+
*/
|
|
39
|
+
import type { Database } from "bun:sqlite";
|
|
40
|
+
import { hash as argonHash } from "@node-rs/argon2";
|
|
41
|
+
import QRCode from "qrcode";
|
|
42
|
+
import { verifyCsrfToken } from "./csrf.ts";
|
|
43
|
+
import { changePasswordRateLimiter, totpEnrollConfirmRateLimiter } from "./rate-limit.ts";
|
|
44
|
+
import { findActiveSession } from "./sessions.ts";
|
|
45
|
+
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
|
|
46
|
+
import {
|
|
47
|
+
clearEnrollment,
|
|
48
|
+
getTotpState,
|
|
49
|
+
isTotpEnrolled,
|
|
50
|
+
persistEnrollment,
|
|
51
|
+
} from "./two-factor-store.ts";
|
|
52
|
+
import {
|
|
53
|
+
PASSWORD_MAX_LEN,
|
|
54
|
+
type User,
|
|
55
|
+
UserNotFoundError,
|
|
56
|
+
getUserById,
|
|
57
|
+
validatePassword,
|
|
58
|
+
verifyPassword,
|
|
59
|
+
} from "./users.ts";
|
|
60
|
+
|
|
61
|
+
export interface ApiAccount2faDeps {
|
|
62
|
+
db: Database;
|
|
63
|
+
/** Test seam — defaults to the real clock. */
|
|
64
|
+
now?: () => Date;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function json(status: number, body: unknown, extra: Record<string, string> = {}): Response {
|
|
68
|
+
return new Response(JSON.stringify(body), {
|
|
69
|
+
status,
|
|
70
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", ...extra },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
75
|
+
return json(status, { error, error_description: description });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve the signed-in user, or an error Response (401). Self-only — no id from the client. */
|
|
79
|
+
function requireUser(
|
|
80
|
+
db: Database,
|
|
81
|
+
req: Request,
|
|
82
|
+
): { ok: true; user: User } | { ok: false; res: Response } {
|
|
83
|
+
const session = findActiveSession(db, req);
|
|
84
|
+
if (!session) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
res: jsonError(401, "unauthenticated", "no session — sign in at /login first"),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const user = getUserById(db, session.userId);
|
|
91
|
+
if (!user) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
res: jsonError(401, "unauthenticated", "signed-in account no longer exists"),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, user };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
|
|
101
|
+
try {
|
|
102
|
+
const body = (await req.json()) as unknown;
|
|
103
|
+
return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
104
|
+
} catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkCsrf(req: Request, body: Record<string, unknown>): boolean {
|
|
110
|
+
const token = typeof body.__csrf === "string" ? body.__csrf : null;
|
|
111
|
+
return verifyCsrfToken(req, token);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Gate the password-verifying endpoints (`/password`, `/2fa/disable`) before the
|
|
116
|
+
* argon2id `verifyPassword` call — a session-hijack attacker shouldn't get an
|
|
117
|
+
* unbounded grind window against the hash. Keyed by `user.id` (identity is
|
|
118
|
+
* already established by the session) and shares the `changePasswordRateLimiter`
|
|
119
|
+
* bucket (3 attempts / 5 min) with the server-rendered change-password POST, so
|
|
120
|
+
* a single user's argon2id budget is uniform across both surfaces. Returns a 429
|
|
121
|
+
* Response when the bucket is exhausted, else null. Fires AFTER CSRF so a junk
|
|
122
|
+
* cross-site POST can't burn the victim's bucket slot.
|
|
123
|
+
*/
|
|
124
|
+
function passwordRateLimit(userId: string, now: () => Date): Response | null {
|
|
125
|
+
const gate = changePasswordRateLimiter.checkAndRecord(userId, now());
|
|
126
|
+
if (gate.allowed) return null;
|
|
127
|
+
const retryAfter = gate.retryAfterSeconds ?? 1;
|
|
128
|
+
return json(
|
|
129
|
+
429,
|
|
130
|
+
{
|
|
131
|
+
error: "too_many_attempts",
|
|
132
|
+
error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
|
|
133
|
+
},
|
|
134
|
+
{ "retry-after": String(retryAfter) },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Router for `/api/account/*`. `subpath` is the path AFTER `/api/account`
|
|
140
|
+
* (e.g. "/2fa/start", "/password"). The hub-server dispatcher slices it.
|
|
141
|
+
*
|
|
142
|
+
* Every route here is a POST (state-changing); the read-side 2FA status the
|
|
143
|
+
* SPA renders comes from `/api/me`'s `two_factor_enabled` field, so there's
|
|
144
|
+
* no GET on this surface.
|
|
145
|
+
*/
|
|
146
|
+
export async function handleApiAccount(
|
|
147
|
+
req: Request,
|
|
148
|
+
subpath: string,
|
|
149
|
+
deps: ApiAccount2faDeps,
|
|
150
|
+
): Promise<Response> {
|
|
151
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
152
|
+
|
|
153
|
+
const gate = requireUser(deps.db, req);
|
|
154
|
+
if (!gate.ok) return gate.res;
|
|
155
|
+
const user = gate.user;
|
|
156
|
+
|
|
157
|
+
const body = await readJsonBody(req);
|
|
158
|
+
if (!checkCsrf(req, body)) {
|
|
159
|
+
return jsonError(403, "csrf_failed", "missing or invalid CSRF token");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
switch (subpath) {
|
|
163
|
+
case "/2fa/start":
|
|
164
|
+
return handleStart(deps.db, user);
|
|
165
|
+
case "/2fa/confirm":
|
|
166
|
+
return handleConfirm(deps, user, body);
|
|
167
|
+
case "/2fa/disable":
|
|
168
|
+
return handleDisable(deps, user, body);
|
|
169
|
+
case "/password":
|
|
170
|
+
return handlePassword(deps, user, body);
|
|
171
|
+
default:
|
|
172
|
+
return jsonError(404, "not_found", `no account route at /api/account${subpath}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* POST /api/account/2fa/start — mint a fresh secret + provisioning artifacts.
|
|
178
|
+
* Refuses if already enrolled (disable first to re-enroll) — same guard as
|
|
179
|
+
* the server-rendered `start`. The secret is NOT persisted; the SPA holds it
|
|
180
|
+
* and round-trips it back on confirm.
|
|
181
|
+
*/
|
|
182
|
+
async function handleStart(db: Database, user: User): Promise<Response> {
|
|
183
|
+
if (isTotpEnrolled(db, user.id)) {
|
|
184
|
+
return jsonError(
|
|
185
|
+
409,
|
|
186
|
+
"already_enrolled",
|
|
187
|
+
"Two-factor is already enabled. Turn it off first to re-enroll.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const { secret, otpauthUrl } = generateTotpSecret(user.username);
|
|
191
|
+
// PNG data-URL QR (margin:1 for scanner-friendly quiet zone). The repo
|
|
192
|
+
// already depends on `qrcode`; returning a data-URL lets the SPA render a
|
|
193
|
+
// plain <img> with no new client dependency, and the otpauth URL is
|
|
194
|
+
// returned alongside for manual-entry / copy affordances.
|
|
195
|
+
const qrDataUrl = await QRCode.toDataURL(otpauthUrl, { margin: 1, errorCorrectionLevel: "M" });
|
|
196
|
+
return json(200, { secret, otpauth_url: otpauthUrl, qr_data_url: qrDataUrl });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** base32 alphabet (A–Z, 2–7) + optional `=` padding, ≥16 chars. Same N1 guard as the HTML flow. */
|
|
200
|
+
function isPlausibleBase32Secret(secret: string): boolean {
|
|
201
|
+
return /^[A-Z2-7]+=*$/i.test(secret) && secret.length >= 16;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* POST /api/account/2fa/confirm {secret, code} — verify the live code vs the
|
|
206
|
+
* in-flight secret, persist enrollment, return the backup codes ONCE.
|
|
207
|
+
*/
|
|
208
|
+
async function handleConfirm(
|
|
209
|
+
deps: ApiAccount2faDeps,
|
|
210
|
+
user: User,
|
|
211
|
+
body: Record<string, unknown>,
|
|
212
|
+
): Promise<Response> {
|
|
213
|
+
const secret = typeof body.secret === "string" ? body.secret : "";
|
|
214
|
+
const code = typeof body.code === "string" ? body.code : "";
|
|
215
|
+
|
|
216
|
+
if (!secret || !isPlausibleBase32Secret(secret)) {
|
|
217
|
+
return jsonError(400, "setup_expired", "Setup expired or malformed. Start again.");
|
|
218
|
+
}
|
|
219
|
+
// Defensive — a confirm POST against an already-enrolled account.
|
|
220
|
+
if (isTotpEnrolled(deps.db, user.id)) {
|
|
221
|
+
return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
|
|
222
|
+
}
|
|
223
|
+
// Bound a hijacked session grinding the in-flight (client-held) secret. Keyed
|
|
224
|
+
// by user.id, lenient (10/15min) so honest enroll mistypes aren't punished —
|
|
225
|
+
// defense-in-depth (#712). Fires AFTER the format + already-enrolled guards so
|
|
226
|
+
// junk/no-op POSTs don't burn the legit enroller's budget, and BEFORE the
|
|
227
|
+
// code verify so the grind window is actually bounded. A SUCCESSFUL confirm
|
|
228
|
+
// also consumes one slot (checkAndRecord counts every attempt) — harmless,
|
|
229
|
+
// since an enrolled account 409s on any further confirm anyway.
|
|
230
|
+
const confirmLimited = totpEnrollConfirmRateLimiter.checkAndRecord(
|
|
231
|
+
user.id,
|
|
232
|
+
deps.now ? deps.now() : new Date(),
|
|
233
|
+
);
|
|
234
|
+
if (!confirmLimited.allowed) {
|
|
235
|
+
const retryAfter = confirmLimited.retryAfterSeconds ?? 1;
|
|
236
|
+
return json(
|
|
237
|
+
429,
|
|
238
|
+
{
|
|
239
|
+
error: "too_many_attempts",
|
|
240
|
+
error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
|
|
241
|
+
},
|
|
242
|
+
{ "retry-after": String(retryAfter) },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
if (!verifyTotpCode(secret, code)) {
|
|
246
|
+
return jsonError(
|
|
247
|
+
400,
|
|
248
|
+
"invalid_code",
|
|
249
|
+
"That code didn't match. Check your device clock and try the current code.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const result = await persistEnrollment(deps.db, user.id, secret, deps.now ?? (() => new Date()));
|
|
253
|
+
// Backup codes are shown ONCE — no-store so the response is never cached.
|
|
254
|
+
return json(200, {
|
|
255
|
+
enrolled: true,
|
|
256
|
+
enrolled_at: result.enrolledAt,
|
|
257
|
+
backup_codes: result.backupCodes,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* POST /api/account/2fa/disable {password} — verify the current password,
|
|
263
|
+
* clear 2FA. Password-gated (same safety as the HTML flow): disabling a
|
|
264
|
+
* second factor with only a session cookie would let a hijacked session
|
|
265
|
+
* strip the very protection that defends the account.
|
|
266
|
+
*/
|
|
267
|
+
async function handleDisable(
|
|
268
|
+
deps: ApiAccount2faDeps,
|
|
269
|
+
user: User,
|
|
270
|
+
body: Record<string, unknown>,
|
|
271
|
+
): Promise<Response> {
|
|
272
|
+
const db = deps.db;
|
|
273
|
+
if (!isTotpEnrolled(db, user.id)) {
|
|
274
|
+
// Idempotent — already off.
|
|
275
|
+
return json(200, { enrolled: false });
|
|
276
|
+
}
|
|
277
|
+
const password = typeof body.password === "string" ? body.password : "";
|
|
278
|
+
if (!password) {
|
|
279
|
+
return jsonError(
|
|
280
|
+
400,
|
|
281
|
+
"password_required",
|
|
282
|
+
"Enter your current password to turn off two-factor.",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
// Cap before argon2id verify (CPU-DoS guard — same posture as /login).
|
|
286
|
+
if (password.length > PASSWORD_MAX_LEN) {
|
|
287
|
+
return jsonError(
|
|
288
|
+
413,
|
|
289
|
+
"password_too_long",
|
|
290
|
+
`Password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
// Rate-limit before the argon2id verify (a stolen session shouldn't grind).
|
|
294
|
+
const limited = passwordRateLimit(user.id, deps.now ?? (() => new Date()));
|
|
295
|
+
if (limited) return limited;
|
|
296
|
+
const ok = await verifyPassword(user, password);
|
|
297
|
+
if (!ok) {
|
|
298
|
+
return jsonError(401, "invalid_credentials", "That password is incorrect.");
|
|
299
|
+
}
|
|
300
|
+
clearEnrollment(db, user.id);
|
|
301
|
+
return json(200, { enrolled: false });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* POST /api/account/password {current_password, new_password} — JSON twin of
|
|
306
|
+
* the server-rendered `/account/change-password` POST. Same validation +
|
|
307
|
+
* atomic hash-write-and-revoke-tokens as `api-account.ts`, reusing the same
|
|
308
|
+
* `users.ts` validators. Self-only (the signed-in user's own hash).
|
|
309
|
+
*
|
|
310
|
+
* Check order mirrors the HTML handler:
|
|
311
|
+
* 1. fields present (400)
|
|
312
|
+
* 2. current too long → 413 (before argon2id verify)
|
|
313
|
+
* 3. new too long → 413 (before argon2id hash)
|
|
314
|
+
* 4. validatePassword(new) → 400
|
|
315
|
+
* 5. rate-limit (429, before the argon2id verify — same as the HTML twin)
|
|
316
|
+
* 6. verifyPassword(current) → 401
|
|
317
|
+
* 7. new === current → 400 (after verify — see api-account.ts rationale)
|
|
318
|
+
* 8. hash new + UPDATE + revoke tokens (one tx)
|
|
319
|
+
*/
|
|
320
|
+
async function handlePassword(
|
|
321
|
+
deps: ApiAccount2faDeps,
|
|
322
|
+
user: User,
|
|
323
|
+
body: Record<string, unknown>,
|
|
324
|
+
): Promise<Response> {
|
|
325
|
+
const currentPassword = typeof body.current_password === "string" ? body.current_password : "";
|
|
326
|
+
const newPassword = typeof body.new_password === "string" ? body.new_password : "";
|
|
327
|
+
|
|
328
|
+
if (!currentPassword || !newPassword) {
|
|
329
|
+
return jsonError(400, "missing_fields", "current_password and new_password are required.");
|
|
330
|
+
}
|
|
331
|
+
if (currentPassword.length > PASSWORD_MAX_LEN) {
|
|
332
|
+
return jsonError(
|
|
333
|
+
413,
|
|
334
|
+
"password_too_long",
|
|
335
|
+
`Current password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (newPassword.length > PASSWORD_MAX_LEN) {
|
|
339
|
+
return jsonError(
|
|
340
|
+
413,
|
|
341
|
+
"password_too_long",
|
|
342
|
+
`New password must be ≤ ${PASSWORD_MAX_LEN} characters.`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (!validatePassword(newPassword).valid) {
|
|
346
|
+
return jsonError(
|
|
347
|
+
400,
|
|
348
|
+
"invalid_password",
|
|
349
|
+
"New password must be at least 12 characters (a passphrase is fine).",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
// Rate-limit before the argon2id verify (a stolen session shouldn't grind
|
|
353
|
+
// the current-password check). Shares the bucket with the HTML twin + the
|
|
354
|
+
// disable endpoint — uniform per-user argon2id budget.
|
|
355
|
+
const limited = passwordRateLimit(user.id, deps.now ?? (() => new Date()));
|
|
356
|
+
if (limited) return limited;
|
|
357
|
+
const currentOk = await verifyPassword(user, currentPassword);
|
|
358
|
+
if (!currentOk) {
|
|
359
|
+
return jsonError(401, "invalid_credentials", "Current password is incorrect.");
|
|
360
|
+
}
|
|
361
|
+
if (newPassword === currentPassword) {
|
|
362
|
+
return jsonError(
|
|
363
|
+
400,
|
|
364
|
+
"password_unchanged",
|
|
365
|
+
"New password must differ from your current password.",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Hash OUTSIDE the transaction — argon2id is async and bun:sqlite's
|
|
370
|
+
// `db.transaction()` is sync; an async closure silently breaks atomicity
|
|
371
|
+
// (same constraint api-account.ts documents). Then write the hash, flip
|
|
372
|
+
// `password_changed`, and revoke the user's still-active tokens in one tx.
|
|
373
|
+
const now = deps.now ?? (() => new Date());
|
|
374
|
+
const passwordHash = await argonHash(newPassword);
|
|
375
|
+
const stamp = now().toISOString();
|
|
376
|
+
try {
|
|
377
|
+
deps.db.transaction(() => {
|
|
378
|
+
const result = deps.db
|
|
379
|
+
.prepare(
|
|
380
|
+
"UPDATE users SET password_hash = ?, password_changed = 1, updated_at = ? WHERE id = ?",
|
|
381
|
+
)
|
|
382
|
+
.run(passwordHash, stamp, user.id);
|
|
383
|
+
if (result.changes === 0) throw new UserNotFoundError(user.id);
|
|
384
|
+
deps.db
|
|
385
|
+
.prepare("UPDATE tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL")
|
|
386
|
+
.run(stamp, user.id);
|
|
387
|
+
})();
|
|
388
|
+
} catch (err) {
|
|
389
|
+
if (err instanceof UserNotFoundError) {
|
|
390
|
+
return jsonError(401, "unauthenticated", "The signed-in account no longer exists.");
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
393
|
+
}
|
|
394
|
+
return json(200, { ok: true });
|
|
395
|
+
}
|
package/src/api-admin-lock.ts
CHANGED
|
@@ -159,8 +159,15 @@ export async function handleAdminLock(
|
|
|
159
159
|
case "/heartbeat":
|
|
160
160
|
// Slide the idle window forward if (and only if) currently unlocked.
|
|
161
161
|
refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
|
|
162
|
+
// `idle_seconds` is part of the response so the heartbeat fulfills the
|
|
163
|
+
// same `AdminLockStatus` shape as GET status — the client re-anchors its
|
|
164
|
+
// local idle timer from it on every heartbeat, so it MUST be present.
|
|
165
|
+
// Omitting it poisoned the client timer with `undefined` (→ NaN → instant
|
|
166
|
+
// re-lock), the bug this fixes. It also lets a live session pick up an
|
|
167
|
+
// idle-window change the operator made in Settings mid-session.
|
|
162
168
|
return json(200, {
|
|
163
169
|
locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
|
|
170
|
+
idle_seconds: getIdleSeconds(db),
|
|
164
171
|
unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
|
|
165
172
|
});
|
|
166
173
|
default:
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -107,6 +107,15 @@ export interface ApiHubUpgradeDeps {
|
|
|
107
107
|
db: Database;
|
|
108
108
|
/** Hub origin — validates the bearer's `iss`. */
|
|
109
109
|
issuer: string;
|
|
110
|
+
/**
|
|
111
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
112
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
113
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
114
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
115
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
116
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
117
|
+
*/
|
|
118
|
+
knownIssuers?: readonly string[];
|
|
110
119
|
/** PARACHUTE_HOME — where the status file is read/written. */
|
|
111
120
|
configDir: string;
|
|
112
121
|
/**
|
|
@@ -155,7 +164,11 @@ async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Respons
|
|
|
155
164
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
156
165
|
if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
|
|
157
166
|
try {
|
|
158
|
-
const validated = await validateAccessToken(
|
|
167
|
+
const validated = await validateAccessToken(
|
|
168
|
+
deps.db,
|
|
169
|
+
bearer,
|
|
170
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
171
|
+
);
|
|
159
172
|
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
160
173
|
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
161
174
|
}
|