@openparachute/hub 0.7.4-rc.2 → 0.7.4-rc.21
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 +4 -11
- package/src/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-lock.test.ts +7 -1
- package/src/__tests__/admin-vaults.test.ts +216 -10
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- 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 +326 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- 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-server.test.ts +127 -5
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/init.test.ts +153 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/oauth-handlers.test.ts +626 -0
- package/src/__tests__/oauth-ui.test.ts +107 -1
- package/src/__tests__/scope-explanations.test.ts +19 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/setup-wizard.test.ts +124 -7
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/__tests__/vault-names.test.ts +32 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- 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-vaults.ts +77 -27
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +52 -4
- 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 +56 -5
- package/src/clients.ts +178 -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/init.ts +108 -0
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +65 -1
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +173 -25
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/managed-unit.ts +30 -1
- package/src/oauth-handlers.ts +110 -7
- package/src/oauth-ui.ts +174 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +2 -1
- package/src/setup-wizard.ts +40 -21
- package/src/supervisor.ts +46 -2
- package/src/vault-names.ts +15 -4
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -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
|
@@ -67,6 +67,34 @@ export const HUB_UPGRADE_REQUIRED_SCOPE = "parachute:host:admin";
|
|
|
67
67
|
*/
|
|
68
68
|
const IN_FLIGHT_PHASES = new Set<HubUpgradeStatus["phase"]>(["pending", "running", "restarting"]);
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* #506: TTL for the 409 in-flight guard. The status file is single-slot, and a
|
|
72
|
+
* helper that CRASHES (OOM, killed mid-rewrite, host reboot) never reaches a
|
|
73
|
+
* terminal phase — leaving the slot stuck in `pending`/`running`/`restarting`
|
|
74
|
+
* FOREVER and 409-deadlocking every future upgrade. So: an in-flight slot whose
|
|
75
|
+
* `started_at` is older than this bound is treated as ABANDONED and the new
|
|
76
|
+
* request proceeds (overwriting the stale slot).
|
|
77
|
+
*
|
|
78
|
+
* 15 minutes — comfortably past the longest expected in-place upgrade (an
|
|
79
|
+
* `npm view` + `bun add -g` rewrite + restart is seconds-to-low-minutes even on
|
|
80
|
+
* a slow box / cold cache). A live upgrade finishing under the bound is never
|
|
81
|
+
* mistaken for abandoned; a crashed one frees the slot within 15 min instead of
|
|
82
|
+
* never. (A missing/garbage `started_at` is treated as stale → not 409, so a
|
|
83
|
+
* malformed file can't deadlock either.)
|
|
84
|
+
*/
|
|
85
|
+
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is an in-flight slot still FRESH (within the TTL), so a second POST must be
|
|
89
|
+
* rejected 409? An unparseable / missing `started_at` is treated as stale
|
|
90
|
+
* (not fresh) so a malformed file frees the slot rather than deadlocking it.
|
|
91
|
+
*/
|
|
92
|
+
function isInFlightFresh(existing: HubUpgradeStatus, now: Date): boolean {
|
|
93
|
+
const startedMs = Date.parse(existing.started_at);
|
|
94
|
+
if (Number.isNaN(startedMs)) return false;
|
|
95
|
+
return now.getTime() - startedMs < IN_FLIGHT_TTL_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
export interface SpawnHelperArgs {
|
|
71
99
|
operationId: string;
|
|
72
100
|
channel: "rc" | "latest";
|
|
@@ -79,6 +107,15 @@ export interface ApiHubUpgradeDeps {
|
|
|
79
107
|
db: Database;
|
|
80
108
|
/** Hub origin — validates the bearer's `iss`. */
|
|
81
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[];
|
|
82
119
|
/** PARACHUTE_HOME — where the status file is read/written. */
|
|
83
120
|
configDir: string;
|
|
84
121
|
/**
|
|
@@ -127,7 +164,11 @@ async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Respons
|
|
|
127
164
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
128
165
|
if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
|
|
129
166
|
try {
|
|
130
|
-
const validated = await validateAccessToken(
|
|
167
|
+
const validated = await validateAccessToken(
|
|
168
|
+
deps.db,
|
|
169
|
+
bearer,
|
|
170
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
171
|
+
);
|
|
131
172
|
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
132
173
|
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
133
174
|
}
|
|
@@ -213,7 +254,9 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
213
254
|
const parsed = await parseBody(req);
|
|
214
255
|
if (parsed instanceof Response) return parsed;
|
|
215
256
|
|
|
216
|
-
|
|
257
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
258
|
+
|
|
259
|
+
// ── 409 in-flight guard (TTL-bounded) ──────────────────────────────────────
|
|
217
260
|
// The status file is single-slot (one hub, one upgrade). If a prior upgrade
|
|
218
261
|
// is still in a non-terminal phase (pending/running/restarting), starting a
|
|
219
262
|
// SECOND would overwrite its operation_id — and a still-running first helper
|
|
@@ -222,9 +265,15 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
222
265
|
// server-side too (a second tab, a stale page, a scripted POST). Reject with
|
|
223
266
|
// 409 unless the slot is free (no file) or the prior op reached a terminal
|
|
224
267
|
// phase (failed / redeploy-required / succeeded).
|
|
268
|
+
//
|
|
269
|
+
// #506: BUT a non-terminal slot is only a real block while it's FRESH. A
|
|
270
|
+
// helper that crashed (OOM / killed / host reboot) leaves the slot stuck
|
|
271
|
+
// in-flight forever and would 409-deadlock every future upgrade. So an
|
|
272
|
+
// in-flight slot older than IN_FLIGHT_TTL_MS is treated as ABANDONED and the
|
|
273
|
+
// request proceeds (the seeded status below overwrites the stale slot).
|
|
225
274
|
const readStatus = deps.readStatus ?? readHubUpgradeStatus;
|
|
226
275
|
const existing = readStatus(deps.configDir);
|
|
227
|
-
if (existing && IN_FLIGHT_PHASES.has(existing.phase)) {
|
|
276
|
+
if (existing && IN_FLIGHT_PHASES.has(existing.phase) && isInFlightFresh(existing, now)) {
|
|
228
277
|
return jsonError(
|
|
229
278
|
409,
|
|
230
279
|
"upgrade_in_flight",
|
|
@@ -234,7 +283,6 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
234
283
|
|
|
235
284
|
const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
|
|
236
285
|
const env = deps.env ?? process.env;
|
|
237
|
-
const now = (deps.now ?? (() => new Date()))();
|
|
238
286
|
|
|
239
287
|
const currentVersion = (deps.currentVersion ?? (() => defaultCurrentVersion(hubSrcDir)))();
|
|
240
288
|
// Auto-detect the channel from the current version when not explicitly set —
|
package/src/api-hub.ts
CHANGED
|
@@ -52,6 +52,15 @@ export interface ApiHubDeps {
|
|
|
52
52
|
db: Database;
|
|
53
53
|
/** Hub origin — used to validate the bearer's `iss`. */
|
|
54
54
|
issuer: string;
|
|
55
|
+
/**
|
|
56
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
57
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
58
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
59
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
60
|
+
* origin switch (hub#516 parity). Absent → falls back to `[issuer]` (the
|
|
61
|
+
* prior strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
62
|
+
*/
|
|
63
|
+
knownIssuers?: readonly string[];
|
|
55
64
|
/**
|
|
56
65
|
* Override the directory used to locate the hub's package.json and to
|
|
57
66
|
* classify install source. Defaults to `dirname(import.meta.url)` —
|
|
@@ -96,7 +105,7 @@ export async function handleApiHub(req: Request, deps: ApiHubDeps): Promise<Resp
|
|
|
96
105
|
// Bearer-gate on `parachute:host:admin`. Same shape as the other admin
|
|
97
106
|
// endpoints — SPA mints via /admin/host-admin-token.
|
|
98
107
|
try {
|
|
99
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
108
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
100
109
|
} catch (err) {
|
|
101
110
|
return adminAuthErrorResponse(err);
|
|
102
111
|
}
|
package/src/api-invites.ts
CHANGED
|
@@ -52,6 +52,16 @@ export interface ApiInvitesDeps {
|
|
|
52
52
|
db: Database;
|
|
53
53
|
/** Hub origin — JWT `iss` validation AND the base for the redemption URL. */
|
|
54
54
|
issuer: string;
|
|
55
|
+
/**
|
|
56
|
+
* SET of origins the hub answers on (loopback ∪ expose-state ∪ platform ∪
|
|
57
|
+
* per-request `issuer`), built via `buildHubBoundOrigins`. The bearer's
|
|
58
|
+
* `iss` is validated against THIS set rather than the single `issuer`, so a
|
|
59
|
+
* credential minted under a still-valid prior origin keeps working across an
|
|
60
|
+
* origin switch (hub#516 parity). The redemption URL still uses the single
|
|
61
|
+
* canonical `issuer`. Absent → falls back to `[issuer]` (the prior strict
|
|
62
|
+
* per-request behavior; tests/non-HTTP callers unaffected).
|
|
63
|
+
*/
|
|
64
|
+
knownIssuers?: readonly string[];
|
|
55
65
|
manifestPath?: string;
|
|
56
66
|
now?: () => Date;
|
|
57
67
|
}
|
|
@@ -399,7 +409,12 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
|
|
|
399
409
|
try {
|
|
400
410
|
// `requireScope` returns the validated claims; the admin's `sub` is the
|
|
401
411
|
// `created_by` audit anchor (guaranteed present — it throws otherwise).
|
|
402
|
-
const auth = await requireScope(
|
|
412
|
+
const auth = await requireScope(
|
|
413
|
+
deps.db,
|
|
414
|
+
req,
|
|
415
|
+
HOST_ADMIN_SCOPE,
|
|
416
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
417
|
+
);
|
|
403
418
|
authUserId = auth.sub;
|
|
404
419
|
} catch (err) {
|
|
405
420
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
@@ -544,7 +559,7 @@ export async function handleCreateInvite(req: Request, deps: ApiInvitesDeps): Pr
|
|
|
544
559
|
export async function handleListInvites(req: Request, deps: ApiInvitesDeps): Promise<Response> {
|
|
545
560
|
if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
|
|
546
561
|
try {
|
|
547
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
562
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
548
563
|
} catch (err) {
|
|
549
564
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
550
565
|
}
|
|
@@ -564,7 +579,7 @@ export async function handleRevokeInvite(
|
|
|
564
579
|
): Promise<Response> {
|
|
565
580
|
if (req.method !== "DELETE") return jsonError(405, "method_not_allowed", "use DELETE");
|
|
566
581
|
try {
|
|
567
|
-
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
|
|
582
|
+
await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.knownIssuers ?? [deps.issuer]);
|
|
568
583
|
} catch (err) {
|
|
569
584
|
return adminAuthErrorResponse(err as AdminAuthError);
|
|
570
585
|
}
|
package/src/api-me.ts
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
* Response shape:
|
|
15
15
|
*
|
|
16
16
|
* { hasSession: false }
|
|
17
|
-
* { hasSession: true, user: { id, displayName }, csrf: "<token>"
|
|
17
|
+
* { hasSession: true, user: { id, displayName }, csrf: "<token>",
|
|
18
|
+
* two_factor_enabled: boolean }
|
|
19
|
+
*
|
|
20
|
+
* `two_factor_enabled` (hub#85) lets the SPA's "My account" page render the
|
|
21
|
+
* 2FA status without a separate read. It reflects `users.totp_secret` being
|
|
22
|
+
* set for the signed-in user.
|
|
18
23
|
*
|
|
19
24
|
* `displayName` is the user's `username` today — there's no separate
|
|
20
25
|
* display-name field on the User shape. Surfaced under a different key
|
|
@@ -39,6 +44,7 @@
|
|
|
39
44
|
import type { Database } from "bun:sqlite";
|
|
40
45
|
import { ensureCsrfToken } from "./csrf.ts";
|
|
41
46
|
import { findActiveSession } from "./sessions.ts";
|
|
47
|
+
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
42
48
|
import { getUserById } from "./users.ts";
|
|
43
49
|
|
|
44
50
|
export interface ApiMeDeps {
|
|
@@ -59,7 +65,9 @@ interface SignedInUser {
|
|
|
59
65
|
* that mixes states — e.g. `{ hasSession: false, user: staleUser }`
|
|
60
66
|
* fails at the type-check, not just at code-review.
|
|
61
67
|
*/
|
|
62
|
-
type ApiMeResponse =
|
|
68
|
+
type ApiMeResponse =
|
|
69
|
+
| { hasSession: false }
|
|
70
|
+
| { hasSession: true; user: SignedInUser; csrf: string; two_factor_enabled: boolean };
|
|
63
71
|
|
|
64
72
|
export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
|
|
65
73
|
if (req.method !== "GET") {
|
|
@@ -99,6 +107,7 @@ export function handleApiMe(req: Request, deps: ApiMeDeps): Response {
|
|
|
99
107
|
displayName: user.username,
|
|
100
108
|
},
|
|
101
109
|
csrf: csrf.token,
|
|
110
|
+
two_factor_enabled: isTotpEnrolled(deps.db, user.id),
|
|
102
111
|
};
|
|
103
112
|
return new Response(JSON.stringify(body), { status: 200, headers });
|
|
104
113
|
}
|
package/src/api-mint-token.ts
CHANGED
|
@@ -87,6 +87,17 @@ export interface ApiMintTokenDeps {
|
|
|
87
87
|
db: Database;
|
|
88
88
|
/** Hub origin — written into the JWT `iss` of minted tokens AND used to validate the bearer. */
|
|
89
89
|
issuer: string;
|
|
90
|
+
/**
|
|
91
|
+
* SET of origins the hub legitimately answers on (loopback ∪ expose-state ∪
|
|
92
|
+
* platform ∪ per-request `issuer`), built via `buildHubBoundOrigins`. The
|
|
93
|
+
* caller's bearer `iss` is validated against THIS set rather than the single
|
|
94
|
+
* `issuer`, so a credential minted under a still-valid prior origin keeps
|
|
95
|
+
* minting across an origin switch (hub#516 parity — the live "mint refused"
|
|
96
|
+
* after `set-origin`). Minted tokens still carry the single canonical
|
|
97
|
+
* `issuer` as their `iss`. Absent → falls back to `[issuer]` (the prior
|
|
98
|
+
* strict per-request behavior; tests/non-HTTP callers unaffected).
|
|
99
|
+
*/
|
|
100
|
+
knownIssuers?: readonly string[];
|
|
90
101
|
/**
|
|
91
102
|
* Names of vault instances currently registered in services.json (item D /
|
|
92
103
|
* hub#450). When provided, a `vault:<name>:admin` mint whose `<name>` is not
|
|
@@ -133,7 +144,11 @@ export async function handleApiMintToken(req: Request, deps: ApiMintTokenDeps):
|
|
|
133
144
|
let bearerSub: string;
|
|
134
145
|
let bearerScopes: string[];
|
|
135
146
|
try {
|
|
136
|
-
const validated = await validateAccessToken(
|
|
147
|
+
const validated = await validateAccessToken(
|
|
148
|
+
deps.db,
|
|
149
|
+
bearer,
|
|
150
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
151
|
+
);
|
|
137
152
|
const sub = validated.payload.sub;
|
|
138
153
|
if (typeof sub !== "string" || sub.length === 0) {
|
|
139
154
|
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|