@openparachute/hub 0.5.14-rc.2 → 0.5.14-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/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /account/vault-token/<name>` — friend-facing scoped vault token mint.
|
|
3
|
+
*
|
|
4
|
+
* A non-admin friend with a hub session is ALREADY authorized for their
|
|
5
|
+
* assigned vault(s): the OAuth issuer mints `vault:<assigned>:read|write` for
|
|
6
|
+
* them on the no-token connect path. This endpoint lets the same friend mint
|
|
7
|
+
* that same authority *in token form* — a long-lived bearer for a script or
|
|
8
|
+
* headless client that can't open a browser to do interactive OAuth. It is
|
|
9
|
+
* the same authority, just materialized; no escalation.
|
|
10
|
+
*
|
|
11
|
+
* Authorization — the security spine of this surface. The mint is capped to
|
|
12
|
+
* the caller's own authority by three gates, in order:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Session.** A valid, unexpired hub session cookie is required (the
|
|
15
|
+
* friend's). No session → 401. (Resolved via `findActiveSession`, the
|
|
16
|
+
* same gate `/account/change-password` uses.)
|
|
17
|
+
* 2. **Assignment.** The requested `<name>` MUST be one of the session
|
|
18
|
+
* user's `user_vaults` assignments. A vault the user is not assigned to
|
|
19
|
+
* → 403. This is what blocks "mint for a vault I'm not assigned" and
|
|
20
|
+
* cross-vault minting. Read directly from `user_vaults` via
|
|
21
|
+
* `vaultVerbsForUserVault` (which returns `null` for an unassigned
|
|
22
|
+
* vault), NOT from the verb-blind `assignedVaults` array.
|
|
23
|
+
* 3. **Scope cap.** The requested verb MUST be one the user's assignment
|
|
24
|
+
* role permits, and may only ever be `read` or `write` — NEVER `admin`,
|
|
25
|
+
* never a broader scope than the user holds. `vaultVerbsForUserVault`
|
|
26
|
+
* returns the role's verb set (today always `["read", "write"]` since
|
|
27
|
+
* every assignment is `role = 'write'`); a verb outside that set → 403.
|
|
28
|
+
* `admin` is not in the form's vocabulary at all and is rejected at the
|
|
29
|
+
* parse step as an invalid verb.
|
|
30
|
+
*
|
|
31
|
+
* The first admin (unrestricted, empty `assignedVaults`) has no `user_vaults`
|
|
32
|
+
* rows, so gate 2 returns `null` for every vault and the admin gets a 403
|
|
33
|
+
* here too — by design. Admins mint vault tokens through the admin SPA's
|
|
34
|
+
* tokens page (`/admin/vault-admin-token/<name>` → `/api/auth/mint-token`),
|
|
35
|
+
* not this friend surface. This endpoint is exclusively the friend path.
|
|
36
|
+
*
|
|
37
|
+
* CSRF: double-submit cookie, same `__csrf` field + `verifyCsrfToken` as
|
|
38
|
+
* `/account/change-password` and `/logout`. A cross-site POST without the
|
|
39
|
+
* matching cookie/form token → 400.
|
|
40
|
+
*
|
|
41
|
+
* Rate limit: `vaultTokenMintRateLimiter`, per-user (10 / 10 min). Fires
|
|
42
|
+
* after CSRF (so a junk cross-site POST doesn't burn the victim's bucket)
|
|
43
|
+
* and before the mint. A floor against a stolen-cookie mint flood, not the
|
|
44
|
+
* primary defense.
|
|
45
|
+
*
|
|
46
|
+
* Mint: `signAccessToken` (the same machinery the OAuth issuer + admin paths
|
|
47
|
+
* use — no hand-rolled JWT signing) with:
|
|
48
|
+
* - `scopes: ["vault:<name>:<verb>"]`
|
|
49
|
+
* - `audience: "vault.<name>"` (via `inferAudience`; vault validates this
|
|
50
|
+
* against its URL-derived name — identical to the OAuth + admin mints)
|
|
51
|
+
* - `iss`: the hub origin
|
|
52
|
+
* - `sub`: the friend's user id
|
|
53
|
+
* - `vaultScope: [<name>]` — pins the token to that one vault (defense in
|
|
54
|
+
* depth, mirrors the admin vault-token + the rule-2/3 mints in
|
|
55
|
+
* `api-mint-token.ts`)
|
|
56
|
+
* - `ttlSeconds`: 90 days (`ACCOUNT_VAULT_TOKEN_TTL_SECONDS`, matching the
|
|
57
|
+
* CLI/api-mint default)
|
|
58
|
+
* and a `tokens` registry row via `recordTokenMint` (`created_via='cli_mint'`,
|
|
59
|
+
* `userId` = the friend) so the token shows up in the revocation list and the
|
|
60
|
+
* operator's token registry.
|
|
61
|
+
*
|
|
62
|
+
* Response: a re-render of `/account/` (server-rendered, no-JS posture) with
|
|
63
|
+
* the token shown ONCE in a banner. The hub keeps no plaintext copy, so this
|
|
64
|
+
* is the only moment the token string is visible.
|
|
65
|
+
*/
|
|
66
|
+
import type { Database } from "bun:sqlite";
|
|
67
|
+
import {
|
|
68
|
+
ACCOUNT_VAULT_TOKEN_TTL_SECONDS,
|
|
69
|
+
type MintedTokenView,
|
|
70
|
+
renderAccountHome,
|
|
71
|
+
} from "./account-home-ui.ts";
|
|
72
|
+
import { renderAdminError } from "./admin-login-ui.ts";
|
|
73
|
+
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
74
|
+
import { inferAudience } from "./jwt-audience.ts";
|
|
75
|
+
import { recordTokenMint, signAccessToken } from "./jwt-sign.ts";
|
|
76
|
+
import { vaultTokenMintRateLimiter } from "./rate-limit.ts";
|
|
77
|
+
import { findActiveSession } from "./sessions.ts";
|
|
78
|
+
import { isTotpEnrolled } from "./two-factor-store.ts";
|
|
79
|
+
import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } from "./users.ts";
|
|
80
|
+
|
|
81
|
+
/** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
|
|
82
|
+
const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
83
|
+
/** Verbs this surface will ever mint. `admin` is deliberately absent. */
|
|
84
|
+
const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write"];
|
|
85
|
+
/** client_id stamped on the minted JWT + registry row. */
|
|
86
|
+
const ACCOUNT_VAULT_TOKEN_CLIENT_ID = "parachute-account";
|
|
87
|
+
|
|
88
|
+
export interface AccountVaultTokenDeps {
|
|
89
|
+
db: Database;
|
|
90
|
+
/** Hub origin for this request — `iss` of the minted token. */
|
|
91
|
+
hubOrigin: string;
|
|
92
|
+
/** Test seam for the clock (rate limiter + mint). */
|
|
93
|
+
now?: () => Date;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
|
|
97
|
+
return new Response(body, {
|
|
98
|
+
status,
|
|
99
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", ...extra },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the `mintableVerbs` map the account-home renderer needs: each of the
|
|
105
|
+
* user's assigned vaults → the verbs its role permits. Used both on the
|
|
106
|
+
* success re-render (so every tile keeps its mint affordance) and the error
|
|
107
|
+
* re-render. Mirrors the GET handler's construction.
|
|
108
|
+
*/
|
|
109
|
+
function buildMintableVerbs(
|
|
110
|
+
db: Database,
|
|
111
|
+
userId: string,
|
|
112
|
+
assignedVaults: readonly string[],
|
|
113
|
+
): Record<string, VaultVerb[]> {
|
|
114
|
+
const map: Record<string, VaultVerb[]> = {};
|
|
115
|
+
for (const v of assignedVaults) {
|
|
116
|
+
const verbs = vaultVerbsForUserVault(db, userId, v);
|
|
117
|
+
if (verbs && verbs.length > 0) map[v] = verbs;
|
|
118
|
+
}
|
|
119
|
+
return map;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function handleAccountVaultTokenPost(
|
|
123
|
+
req: Request,
|
|
124
|
+
vaultName: string,
|
|
125
|
+
deps: AccountVaultTokenDeps,
|
|
126
|
+
): Promise<Response> {
|
|
127
|
+
if (req.method !== "POST") {
|
|
128
|
+
return htmlResponse("method not allowed", 405);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Gate 1 — session. No identity, no mint.
|
|
132
|
+
const session = findActiveSession(deps.db, req);
|
|
133
|
+
if (!session) {
|
|
134
|
+
return htmlResponse(
|
|
135
|
+
renderAdminError({
|
|
136
|
+
title: "Not signed in",
|
|
137
|
+
message: "Please sign in before minting an access token.",
|
|
138
|
+
}),
|
|
139
|
+
401,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const user = getUserById(deps.db, session.userId);
|
|
143
|
+
if (!user) {
|
|
144
|
+
return htmlResponse(
|
|
145
|
+
renderAdminError({
|
|
146
|
+
title: "Account not found",
|
|
147
|
+
message: "The signed-in account no longer exists. Please sign in again.",
|
|
148
|
+
}),
|
|
149
|
+
401,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// CSRF — verify before any state change / rate-limit bucket touch, same
|
|
154
|
+
// shape + 400 status as `/account/change-password`.
|
|
155
|
+
const form = await req.formData();
|
|
156
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
157
|
+
if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
|
|
158
|
+
return htmlResponse(
|
|
159
|
+
renderAdminError({
|
|
160
|
+
title: "Invalid form submission",
|
|
161
|
+
message: "The form's CSRF token did not match. Reload the page and try again.",
|
|
162
|
+
}),
|
|
163
|
+
400,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Helper to re-render the account home (success banner or inline error).
|
|
168
|
+
// Re-resolves the CSRF token + 2FA state so the page stays fully usable.
|
|
169
|
+
const renderHome = (
|
|
170
|
+
status: number,
|
|
171
|
+
extras: { mintedToken?: MintedTokenView; mintError?: string },
|
|
172
|
+
): Response => {
|
|
173
|
+
const csrf = ensureCsrfToken(req);
|
|
174
|
+
const setCookie: Record<string, string> = csrf.setCookie
|
|
175
|
+
? { "set-cookie": csrf.setCookie }
|
|
176
|
+
: {};
|
|
177
|
+
const adminFlag = isFirstAdmin(deps.db, user.id);
|
|
178
|
+
return htmlResponse(
|
|
179
|
+
renderAccountHome({
|
|
180
|
+
username: user.username,
|
|
181
|
+
assignedVaults: user.assignedVaults,
|
|
182
|
+
passwordChanged: user.passwordChanged,
|
|
183
|
+
hubOrigin: deps.hubOrigin,
|
|
184
|
+
isFirstAdmin: adminFlag,
|
|
185
|
+
csrfToken: csrf.token,
|
|
186
|
+
twoFactorEnabled: isTotpEnrolled(deps.db, user.id),
|
|
187
|
+
mintableVerbs: buildMintableVerbs(deps.db, user.id, user.assignedVaults),
|
|
188
|
+
...extras,
|
|
189
|
+
}),
|
|
190
|
+
status,
|
|
191
|
+
setCookie,
|
|
192
|
+
);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Vault-name shape guard — reject anything that can't be a services.json
|
|
196
|
+
// key before any DB / authority work (router can hand us arbitrary path
|
|
197
|
+
// segments). Same posture as `/admin/vault-admin-token`.
|
|
198
|
+
if (!VAULT_NAME_RE.test(vaultName)) {
|
|
199
|
+
return renderHome(400, { mintError: `"${vaultName}" is not a valid vault name.` });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Verb parse — must be exactly one of read/write. `admin` (or anything
|
|
203
|
+
// else) is not in this surface's vocabulary and is rejected here, well
|
|
204
|
+
// before authority is even consulted.
|
|
205
|
+
const verbRaw = form.get("verb");
|
|
206
|
+
const verb = typeof verbRaw === "string" ? verbRaw : "";
|
|
207
|
+
if (!ALLOWED_VERBS.includes(verb as VaultVerb)) {
|
|
208
|
+
return renderHome(400, {
|
|
209
|
+
mintError: "Pick an access level (read or write).",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const requestedVerb = verb as VaultVerb;
|
|
213
|
+
|
|
214
|
+
// Gate 2 + 3 — assignment + scope cap. `vaultVerbsForUserVault` returns:
|
|
215
|
+
// - null → the user has NO assignment for this vault (gate 2 fail): 403.
|
|
216
|
+
// - [] → assignment exists but its role grants no mintable verb
|
|
217
|
+
// (fail-closed for an unknown role): 403.
|
|
218
|
+
// - [...] → the verbs the assignment role permits. The requested verb
|
|
219
|
+
// must be in this set (gate 3): else 403.
|
|
220
|
+
// This is the cap to the user's actual authority — it blocks minting for
|
|
221
|
+
// an unassigned vault, a broader verb than the role grants, and (since the
|
|
222
|
+
// set never contains `admin`) any admin escalation.
|
|
223
|
+
const allowedForUser = vaultVerbsForUserVault(deps.db, user.id, vaultName);
|
|
224
|
+
if (allowedForUser === null) {
|
|
225
|
+
return renderHome(403, {
|
|
226
|
+
mintError: `You're not assigned to a vault named "${vaultName}", so you can't mint a token for it. Ask the hub operator if you think this is wrong.`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (!allowedForUser.includes(requestedVerb)) {
|
|
230
|
+
return renderHome(403, {
|
|
231
|
+
mintError: `Your access to "${vaultName}" doesn't allow minting a ${requestedVerb} token.`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Rate limit — after CSRF + authority shape, before the mint. Per-user.
|
|
236
|
+
const rlNow = (deps.now ?? (() => new Date()))();
|
|
237
|
+
const gate = vaultTokenMintRateLimiter.checkAndRecord(user.id, rlNow);
|
|
238
|
+
if (!gate.allowed) {
|
|
239
|
+
return renderHome(429, {
|
|
240
|
+
mintError: `Too many token-mint attempts. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Mint — the same machinery the OAuth issuer + admin paths use. The scope
|
|
245
|
+
// is exactly the capped `vault:<name>:<verb>`; the audience binds the token
|
|
246
|
+
// to that one vault; `vaultScope` pins it as defense in depth.
|
|
247
|
+
const scope = `vault:${vaultName}:${requestedVerb}`;
|
|
248
|
+
const audience = inferAudience([scope]); // → `vault.<name>`
|
|
249
|
+
const minted = await signAccessToken(deps.db, {
|
|
250
|
+
sub: user.id,
|
|
251
|
+
scopes: [scope],
|
|
252
|
+
audience,
|
|
253
|
+
clientId: ACCOUNT_VAULT_TOKEN_CLIENT_ID,
|
|
254
|
+
issuer: deps.hubOrigin,
|
|
255
|
+
ttlSeconds: ACCOUNT_VAULT_TOKEN_TTL_SECONDS,
|
|
256
|
+
vaultScope: [vaultName],
|
|
257
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
recordTokenMint(deps.db, {
|
|
261
|
+
jti: minted.jti,
|
|
262
|
+
createdVia: "cli_mint",
|
|
263
|
+
subject: user.id,
|
|
264
|
+
// Anchor the registry row to the friend's user id so the operator's
|
|
265
|
+
// token registry + the revocation list attribute it correctly, and a
|
|
266
|
+
// future per-user revoke surface can find it.
|
|
267
|
+
userId: user.id,
|
|
268
|
+
clientId: ACCOUNT_VAULT_TOKEN_CLIENT_ID,
|
|
269
|
+
scopes: [scope],
|
|
270
|
+
expiresAt: minted.expiresAt,
|
|
271
|
+
...(deps.now !== undefined ? { now: deps.now } : {}),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return renderHome(200, {
|
|
275
|
+
mintedToken: {
|
|
276
|
+
vaultName,
|
|
277
|
+
verb: requestedVerb,
|
|
278
|
+
token: minted.token,
|
|
279
|
+
expiresInDays: Math.round(ACCOUNT_VAULT_TOKEN_TTL_SECONDS / (24 * 60 * 60)),
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
}
|
package/src/admin-handlers.ts
CHANGED
|
@@ -11,9 +11,17 @@
|
|
|
11
11
|
* (`parachute_hub_csrf` cookie + `__csrf` form field, constant-time compare).
|
|
12
12
|
*/
|
|
13
13
|
import type { Database } from "bun:sqlite";
|
|
14
|
-
import { renderAdminError, renderAdminLogin } from "./admin-login-ui.ts";
|
|
14
|
+
import { renderAdminError, renderAdminLogin, renderTotpChallenge } from "./admin-login-ui.ts";
|
|
15
15
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
buildPendingLoginClearCookie,
|
|
18
|
+
buildPendingLoginCookie,
|
|
19
|
+
consumePendingLogin,
|
|
20
|
+
createPendingLogin,
|
|
21
|
+
getPendingLogin,
|
|
22
|
+
parsePendingLoginCookie,
|
|
23
|
+
} from "./pending-login.ts";
|
|
24
|
+
import { checkAndRecord, clientIpFromRequest, totpRateLimiter } from "./rate-limit.ts";
|
|
17
25
|
import { isHttpsRequest } from "./request-protocol.ts";
|
|
18
26
|
import {
|
|
19
27
|
SESSION_TTL_MS,
|
|
@@ -23,9 +31,11 @@ import {
|
|
|
23
31
|
deleteSession,
|
|
24
32
|
parseSessionCookie,
|
|
25
33
|
} from "./sessions.ts";
|
|
34
|
+
import { isTotpEnrolled, verifySecondFactor } from "./two-factor-store.ts";
|
|
26
35
|
import {
|
|
27
36
|
PASSWORD_MAX_LEN,
|
|
28
37
|
type User,
|
|
38
|
+
getUserById,
|
|
29
39
|
getUserByUsername,
|
|
30
40
|
isFirstAdmin,
|
|
31
41
|
verifyPassword,
|
|
@@ -214,8 +224,42 @@ export async function handleAdminLoginPost(
|
|
|
214
224
|
401,
|
|
215
225
|
);
|
|
216
226
|
}
|
|
227
|
+
|
|
228
|
+
// 2FA gate (hub#473). If the user has TOTP enrolled, the password is only
|
|
229
|
+
// the *first* factor — do NOT mint a session yet. Stash a short-lived
|
|
230
|
+
// pending-login (server-side, keyed by an opaque cookie token) and render
|
|
231
|
+
// the "enter your code" page. The session is minted in
|
|
232
|
+
// `handleAdminLoginTotpPost` only after the second factor verifies.
|
|
233
|
+
// Users WITHOUT 2FA fall through to the existing password-only path
|
|
234
|
+
// unchanged — existing operators keep signing in exactly as before.
|
|
235
|
+
if (isTotpEnrolled(db, user.id)) {
|
|
236
|
+
const pendingToken = createPendingLogin(user.id, next);
|
|
237
|
+
// Reuse the same CSRF token (cookie unchanged) for the challenge form.
|
|
238
|
+
return htmlResponse(renderTotpChallenge({ next, csrfToken }), 200, {
|
|
239
|
+
"set-cookie": buildPendingLoginCookie(pendingToken, req),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return mintSessionAndRedirect(db, req, user, next);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Mint a session for an authenticated user and 302 to the resolved target.
|
|
248
|
+
* Shared by the password-only login path and the post-2FA path so both apply
|
|
249
|
+
* the identical force-change-password / friend-rewrite redirect logic.
|
|
250
|
+
*
|
|
251
|
+
* `extraCookies` lets the 2FA path also clear the pending-login cookie in the
|
|
252
|
+
* same response.
|
|
253
|
+
*/
|
|
254
|
+
function mintSessionAndRedirect(
|
|
255
|
+
db: Database,
|
|
256
|
+
req: Request,
|
|
257
|
+
user: User,
|
|
258
|
+
next: string,
|
|
259
|
+
extraCookies: string[] = [],
|
|
260
|
+
): Response {
|
|
217
261
|
const session = createSession(db, { userId: user.id });
|
|
218
|
-
const
|
|
262
|
+
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
219
263
|
secure: isHttpsRequest(req),
|
|
220
264
|
});
|
|
221
265
|
// Multi-user Phase 1 PR 3 — `password_changed === false` (admin-created
|
|
@@ -226,7 +270,118 @@ export async function handleAdminLoginPost(
|
|
|
226
270
|
// `next` rides along on the change-password URL so the post-change
|
|
227
271
|
// POST can land them at their intended destination.
|
|
228
272
|
const target = loginRedirectTarget(db, user, next);
|
|
229
|
-
|
|
273
|
+
const headers = new Headers({ location: target });
|
|
274
|
+
headers.append("set-cookie", sessionCookie);
|
|
275
|
+
for (const c of extraCookies) headers.append("set-cookie", c);
|
|
276
|
+
return new Response(null, { status: 302, headers });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* POST `/login/2fa` — the second-factor step (hub#473).
|
|
281
|
+
*
|
|
282
|
+
* Reached only after a correct password POST for a 2FA-enrolled user handed
|
|
283
|
+
* back a pending-login cookie. Order of checks:
|
|
284
|
+
* 1. CSRF (else 400 — same shape as `/login`).
|
|
285
|
+
* 2. Per-IP rate-limit (5 / 15 min) BEFORE the factor check, so backup-code
|
|
286
|
+
* / TOTP grinding by a password-holder is bounded.
|
|
287
|
+
* 3. Pending-login cookie resolves to a live half-login (else 401 — the
|
|
288
|
+
* pending login expired or was never created; restart the password step).
|
|
289
|
+
* 4. The user row still exists + still has 2FA enrolled (defensive).
|
|
290
|
+
* 5. Verify the submitted code as TOTP (±1 window) OR a backup code (single-
|
|
291
|
+
* use, consumed on match). On success: consume the pending login, mint
|
|
292
|
+
* the session, 302 to the resolved target + clear the pending cookie.
|
|
293
|
+
* On failure: re-render the challenge with an error (the pending login
|
|
294
|
+
* stays valid so the user can retry without re-entering the password).
|
|
295
|
+
*/
|
|
296
|
+
export async function handleAdminLoginTotpPost(
|
|
297
|
+
db: Database,
|
|
298
|
+
req: Request,
|
|
299
|
+
deps: AdminLoginDeps = {},
|
|
300
|
+
): Promise<Response> {
|
|
301
|
+
const form = await req.formData();
|
|
302
|
+
const formCsrf = form.get(CSRF_FIELD_NAME);
|
|
303
|
+
const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
|
|
304
|
+
if (!verifyCsrfToken(req, csrfToken || null)) {
|
|
305
|
+
return htmlResponse(
|
|
306
|
+
renderAdminError({
|
|
307
|
+
title: "Invalid form submission",
|
|
308
|
+
message: "The form's CSRF token did not match. Reload the page and try again.",
|
|
309
|
+
}),
|
|
310
|
+
400,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const next = safeNext(String(form.get("next") ?? ""));
|
|
315
|
+
const code = String(form.get("code") ?? "");
|
|
316
|
+
|
|
317
|
+
// Rate-limit BEFORE resolving the pending login or verifying the factor.
|
|
318
|
+
const clientIp = clientIpFromRequest(req);
|
|
319
|
+
const now = deps.now ? deps.now() : new Date();
|
|
320
|
+
const gate = totpRateLimiter.checkAndRecord(clientIp, now);
|
|
321
|
+
if (!gate.allowed) {
|
|
322
|
+
return htmlResponse(
|
|
323
|
+
renderAdminError({
|
|
324
|
+
title: "Too many attempts",
|
|
325
|
+
message: `Too many verification attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
|
|
326
|
+
}),
|
|
327
|
+
429,
|
|
328
|
+
{ "retry-after": String(gate.retryAfterSeconds ?? 1) },
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
|
|
333
|
+
const pending = getPendingLogin(pendingToken, () => now);
|
|
334
|
+
if (!pending) {
|
|
335
|
+
// No live pending login — expired, missing, or tampered. Send the user
|
|
336
|
+
// back to the start; clear any stale pending cookie.
|
|
337
|
+
return htmlResponse(
|
|
338
|
+
renderAdminError({
|
|
339
|
+
title: "Session expired",
|
|
340
|
+
message: "Your sign-in attempt expired. Please sign in again.",
|
|
341
|
+
}),
|
|
342
|
+
401,
|
|
343
|
+
{ "set-cookie": buildPendingLoginClearCookie(req) },
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const user = getUserById(db, pending.userId);
|
|
348
|
+
if (!user || !isTotpEnrolled(db, user.id)) {
|
|
349
|
+
consumePendingLogin(pendingToken);
|
|
350
|
+
return htmlResponse(
|
|
351
|
+
renderAdminError({
|
|
352
|
+
title: "Sign-in unavailable",
|
|
353
|
+
message: "Please sign in again.",
|
|
354
|
+
}),
|
|
355
|
+
401,
|
|
356
|
+
{ "set-cookie": buildPendingLoginClearCookie(req) },
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!code) {
|
|
361
|
+
return htmlResponse(
|
|
362
|
+
renderTotpChallenge({ next, csrfToken, errorMessage: "Enter your authentication code." }),
|
|
363
|
+
400,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const result = await verifySecondFactor(db, user.id, code);
|
|
368
|
+
if (!result.ok) {
|
|
369
|
+
return htmlResponse(
|
|
370
|
+
renderTotpChallenge({
|
|
371
|
+
next,
|
|
372
|
+
csrfToken,
|
|
373
|
+
errorMessage: "That code is incorrect or expired. Try again.",
|
|
374
|
+
}),
|
|
375
|
+
401,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Second factor good. Consume the pending login (single use), mint the
|
|
380
|
+
// session, and clear the pending cookie in the same response. Use the
|
|
381
|
+
// pending login's stored `next` if the form's was tampered to the default.
|
|
382
|
+
consumePendingLogin(pendingToken);
|
|
383
|
+
const target = pending.next && pending.next !== POST_LOGIN_DEFAULT ? pending.next : next;
|
|
384
|
+
return mintSessionAndRedirect(db, req, user, target, [buildPendingLoginClearCookie(req)]);
|
|
230
385
|
}
|
|
231
386
|
|
|
232
387
|
/**
|
package/src/admin-login-ui.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Pure functions — DB, sessions live in `admin-handlers.ts`.
|
|
14
14
|
*/
|
|
15
|
-
import {
|
|
15
|
+
import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
|
|
16
16
|
import { renderCsrfHiddenInput } from "./csrf.ts";
|
|
17
17
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
18
18
|
|
|
@@ -67,7 +67,7 @@ function header(): string {
|
|
|
67
67
|
<div class="brand">
|
|
68
68
|
<span class="brand-mark" aria-hidden="true">${brandMarkSvg(20, "admin-login")}</span>
|
|
69
69
|
<span class="brand-name">${WORDMARK_TEXT}</span>
|
|
70
|
-
<span class="brand-tag">
|
|
70
|
+
<span class="brand-tag">sign in</span>
|
|
71
71
|
</div>`;
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -87,8 +87,8 @@ export function renderAdminLogin(props: AdminLoginProps): string {
|
|
|
87
87
|
<div class="card">
|
|
88
88
|
<div class="card-header">
|
|
89
89
|
${header()}
|
|
90
|
-
<h1>Sign in</h1>
|
|
91
|
-
<p class="subtitle">
|
|
90
|
+
<h1>Sign in to your Parachute account</h1>
|
|
91
|
+
<p class="subtitle">Hub operators and invited members sign in here.</p>
|
|
92
92
|
</div>
|
|
93
93
|
${error}
|
|
94
94
|
<form method="POST" action="/login" class="auth-form">
|
|
@@ -105,7 +105,51 @@ export function renderAdminLogin(props: AdminLoginProps): string {
|
|
|
105
105
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
|
106
106
|
</form>
|
|
107
107
|
</div>`;
|
|
108
|
-
return baseDocument("Sign in
|
|
108
|
+
return baseDocument("Sign in — Parachute", body);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- /login/2fa (second-factor step) --------------------------------------
|
|
112
|
+
|
|
113
|
+
export interface TotpChallengeProps {
|
|
114
|
+
/** Continuation path after a successful second factor — hidden field. */
|
|
115
|
+
next: string;
|
|
116
|
+
csrfToken: string;
|
|
117
|
+
errorMessage?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Server-rendered "enter your code" page shown after a correct password when
|
|
122
|
+
* the user has 2FA enrolled (hub#473). Posts to `/login/2fa` along with the
|
|
123
|
+
* pending-login cookie minted by the password step. Accepts either a 6-digit
|
|
124
|
+
* TOTP code or a backup code in the same field — the handler tries TOTP first,
|
|
125
|
+
* then backup codes.
|
|
126
|
+
*
|
|
127
|
+
* No JS, stands alone like `/login`. `inputmode`/`autocomplete` hint mobile
|
|
128
|
+
* keyboards toward a numeric pad + the platform's one-time-code autofill.
|
|
129
|
+
*/
|
|
130
|
+
export function renderTotpChallenge(props: TotpChallengeProps): string {
|
|
131
|
+
const { next, csrfToken, errorMessage } = props;
|
|
132
|
+
const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
|
|
133
|
+
const body = `
|
|
134
|
+
<div class="card">
|
|
135
|
+
<div class="card-header">
|
|
136
|
+
${header()}
|
|
137
|
+
<h1>Two-factor authentication</h1>
|
|
138
|
+
<p class="subtitle">Enter the 6-digit code from your authenticator app, or a backup code.</p>
|
|
139
|
+
</div>
|
|
140
|
+
${error}
|
|
141
|
+
<form method="POST" action="/login/2fa" class="auth-form">
|
|
142
|
+
${renderCsrfHiddenInput(csrfToken)}
|
|
143
|
+
<input type="hidden" name="next" value="${escapeAttr(next)}" />
|
|
144
|
+
<label class="field">
|
|
145
|
+
<span class="field-label">Authentication code</span>
|
|
146
|
+
<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code"
|
|
147
|
+
autofocus required placeholder="123456" />
|
|
148
|
+
</label>
|
|
149
|
+
<button type="submit" class="btn btn-primary">Verify</button>
|
|
150
|
+
</form>
|
|
151
|
+
</div>`;
|
|
152
|
+
return baseDocument("Two-factor authentication — Parachute", body);
|
|
109
153
|
}
|
|
110
154
|
|
|
111
155
|
// --- error page ------------------------------------------------------------
|
package/src/admin-vaults.ts
CHANGED
|
@@ -12,16 +12,24 @@
|
|
|
12
12
|
* Content-Type: application/json
|
|
13
13
|
* { "name": "<vault-name>" }
|
|
14
14
|
*
|
|
15
|
-
* 201 → { name, url, version, token?, paths? }
|
|
16
|
-
* // vault freshly created. `token`
|
|
17
|
-
* //
|
|
18
|
-
* // `parachute-vault create --json` branch —
|
|
19
|
-
* //
|
|
20
|
-
* //
|
|
21
|
-
* //
|
|
15
|
+
* 201 → { name, url, version, token?, token_guidance?, paths? }
|
|
16
|
+
* // vault freshly created. `token` is a hub-issued ACCESS token
|
|
17
|
+
* // (a JWT scoped `vault:<name>:admin`) captured from the
|
|
18
|
+
* // `parachute-vault create --json` branch — NOT a `pvt_*` vault
|
|
19
|
+
* // token (those were dropped). Post-DROP `token` may be the
|
|
20
|
+
* // empty string `""` when the bootstrap mint was unavailable
|
|
21
|
+
* // (e.g. a loopback origin the hub can't mint against); in that
|
|
22
|
+
* // case `token_guidance` carries the vault's human-readable
|
|
23
|
+
* // reason, forwarded verbatim so the SPA can explain the gap.
|
|
24
|
+
* // `paths` is the new vault's filesystem layout. The
|
|
25
|
+
* // first-vault-on-host bootstrap (`parachute install vault`)
|
|
26
|
+
* // doesn't emit JSON yet, so a fresh-box response carries
|
|
27
|
+
* // name/url/version only.
|
|
22
28
|
* 200 → { name, url, version }
|
|
23
29
|
* // idempotent re-POST: existing vault. Never includes `token` —
|
|
24
|
-
* //
|
|
30
|
+
* // the create-time access token isn't retrievable later. The
|
|
31
|
+
* // caller branches on HTTP status (201 vs 200), not on `token`
|
|
32
|
+
* // truthiness, so an empty-token 201 isn't confused with a 200.
|
|
25
33
|
* 400 → { error: "invalid_request", error_description: ... }
|
|
26
34
|
* 401/403 → bearer-auth failure
|
|
27
35
|
* 500 → orchestration failure
|
|
@@ -50,7 +58,7 @@
|
|
|
50
58
|
import type { Database } from "bun:sqlite";
|
|
51
59
|
import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
52
60
|
import { SERVICES_MANIFEST_PATH } from "./config.ts";
|
|
53
|
-
import { findService, readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
61
|
+
import { findService, type readManifest, readManifestLenient } from "./services-manifest.ts";
|
|
54
62
|
import { type WellKnownVaultEntry, isVaultEntry, vaultInstanceNameFor } from "./well-known.ts";
|
|
55
63
|
|
|
56
64
|
/** Scope required to call POST /vaults. */
|
|
@@ -74,7 +82,20 @@ export interface CreateVaultRequest {
|
|
|
74
82
|
/** Output shape of `parachute-vault create --json` (vault PR #184). */
|
|
75
83
|
export interface VaultCreateJson {
|
|
76
84
|
name: string;
|
|
85
|
+
/**
|
|
86
|
+
* Hub-issued access token (a JWT scoped `vault:<name>:admin`) the vault
|
|
87
|
+
* minted at create time. Post the pvt_* DROP this is the empty string
|
|
88
|
+
* `""` when no hub origin was reachable to mint against (e.g. a loopback
|
|
89
|
+
* create) — the field is always present but may be empty.
|
|
90
|
+
*/
|
|
77
91
|
token: string;
|
|
92
|
+
/**
|
|
93
|
+
* Vault-supplied human-readable reason no token was minted, present only
|
|
94
|
+
* when `token` is empty (e.g. "no hub origin reachable to mint against").
|
|
95
|
+
* Optional — older vaults that always minted don't emit it. Forwarded
|
|
96
|
+
* verbatim to the caller so the SPA can explain the empty-token state.
|
|
97
|
+
*/
|
|
98
|
+
token_guidance?: string;
|
|
78
99
|
paths: {
|
|
79
100
|
vault_dir: string;
|
|
80
101
|
vault_db: string;
|
|
@@ -239,8 +260,9 @@ interface OrchestrateError {
|
|
|
239
260
|
* Run the orchestration step. Picks `parachute install` (bootstrap) vs
|
|
240
261
|
* `parachute-vault create --json` (subsequent) based on whether vault is
|
|
241
262
|
* already registered in services.json. The create branch parses stdout for
|
|
242
|
-
* the just-
|
|
243
|
-
*
|
|
263
|
+
* the just-minted hub access token (a `vault:<name>:admin` JWT, possibly
|
|
264
|
+
* empty post-DROP), the optional `token_guidance`, and filesystem paths so
|
|
265
|
+
* the caller can talk to the new vault — the access token is single-emit.
|
|
244
266
|
*/
|
|
245
267
|
async function orchestrate(
|
|
246
268
|
manifestPath: string,
|
|
@@ -348,14 +370,25 @@ export async function handleCreateVault(req: Request, deps: CreateVaultDeps): Pr
|
|
|
348
370
|
}
|
|
349
371
|
|
|
350
372
|
const entry = buildEntry(name, created.path, created.version, deps.issuer);
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
373
|
+
// Access token (a `vault:<name>:admin` JWT, possibly empty post-DROP) +
|
|
374
|
+
// filesystem paths are single-emit at create time. We surface them here so
|
|
375
|
+
// the caller can immediately bootstrap a connection to the new vault.
|
|
376
|
+
// `token_guidance` (when the vault couldn't mint) is forwarded verbatim so
|
|
377
|
+
// the SPA can explain the empty-token state rather than rendering a blank.
|
|
378
|
+
// Idempotent re-POSTs intentionally never include any of these.
|
|
354
379
|
const body: WellKnownVaultEntry & {
|
|
355
380
|
token?: string;
|
|
381
|
+
token_guidance?: string;
|
|
356
382
|
paths?: VaultCreateJson["paths"];
|
|
357
383
|
} = result.createJson
|
|
358
|
-
? {
|
|
384
|
+
? {
|
|
385
|
+
...entry,
|
|
386
|
+
token: result.createJson.token,
|
|
387
|
+
...(result.createJson.token_guidance
|
|
388
|
+
? { token_guidance: result.createJson.token_guidance }
|
|
389
|
+
: {}),
|
|
390
|
+
paths: result.createJson.paths,
|
|
391
|
+
}
|
|
359
392
|
: entry;
|
|
360
393
|
|
|
361
394
|
return new Response(JSON.stringify(body), {
|