@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
package/src/commands/auth.ts
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
* - `list-users` — show accounts in `users`.
|
|
14
14
|
*
|
|
15
15
|
* Vault-forwarded subcommands (still implemented in `parachute-vault`):
|
|
16
|
-
* - `2fa`
|
|
16
|
+
* - (none currently). `2fa` used to forward here, but the legacy
|
|
17
|
+
* `parachute-vault 2fa` stub writes vault YAML that does NOT gate hub
|
|
18
|
+
* `/login`. As of hub#473 `parachute auth 2fa` is the REAL hub-login TOTP
|
|
19
|
+
* surface: it reads/writes the hub.db `users` TOTP columns and gates
|
|
20
|
+
* `/login`. Subcommands: `status`, `enroll` (CLI text enroll — prints the
|
|
21
|
+
* otpauth:// URI + base32 secret for manual authenticator entry, then
|
|
22
|
+
* prompts for the confirm code), `disenroll`. The browser path lives at
|
|
23
|
+
* `<hub-origin>/account/2fa` (QR + backup codes).
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import { join } from "node:path";
|
|
@@ -44,6 +51,13 @@ import {
|
|
|
44
51
|
} from "../operator-token.ts";
|
|
45
52
|
import { isNonRequestableScope } from "../scope-explanations.ts";
|
|
46
53
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
54
|
+
import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "../totp.ts";
|
|
55
|
+
import {
|
|
56
|
+
clearEnrollment,
|
|
57
|
+
getTotpState,
|
|
58
|
+
isTotpEnrolled,
|
|
59
|
+
persistEnrollment,
|
|
60
|
+
} from "../two-factor-store.ts";
|
|
47
61
|
import {
|
|
48
62
|
SingleUserModeError,
|
|
49
63
|
UsernameTakenError,
|
|
@@ -71,9 +85,10 @@ export const defaultRunner: Runner = {
|
|
|
71
85
|
},
|
|
72
86
|
};
|
|
73
87
|
|
|
74
|
-
const VAULT_FORWARDED_SUBCOMMANDS = new Set([
|
|
88
|
+
const VAULT_FORWARDED_SUBCOMMANDS = new Set<string>([]);
|
|
75
89
|
const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
76
90
|
"rotate-key",
|
|
91
|
+
"2fa",
|
|
77
92
|
"set-password",
|
|
78
93
|
"list-users",
|
|
79
94
|
"rotate-operator",
|
|
@@ -92,10 +107,14 @@ Usage:
|
|
|
92
107
|
parachute auth set-password [--username <name>] [--password <pw>] [--allow-multi]
|
|
93
108
|
Create or update the hub user's password
|
|
94
109
|
parachute auth list-users Show registered hub accounts
|
|
95
|
-
parachute auth 2fa status
|
|
96
|
-
parachute auth 2fa enroll
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
parachute auth 2fa [status] Show hub-login 2FA (TOTP) status
|
|
111
|
+
parachute auth 2fa enroll [--username <name>]
|
|
112
|
+
Enroll TOTP for hub login (prints the
|
|
113
|
+
otpauth:// URI + base32 secret for manual
|
|
114
|
+
authenticator entry, then prompts for a
|
|
115
|
+
confirm code; prints backup codes once)
|
|
116
|
+
parachute auth 2fa disenroll [--username <name>]
|
|
117
|
+
Turn off hub-login 2FA for the account
|
|
99
118
|
parachute auth rotate-key Rotate the hub's JWT signing key
|
|
100
119
|
parachute auth rotate-operator [--scope-set <set>]
|
|
101
120
|
Mint a fresh ~/.parachute/operator.token
|
|
@@ -128,10 +147,20 @@ The default username on first run is "owner" — override with --username.
|
|
|
128
147
|
Single-user mode is the default; pass --allow-multi to add additional
|
|
129
148
|
accounts beyond the first.
|
|
130
149
|
|
|
131
|
-
2fa
|
|
132
|
-
|
|
150
|
+
2fa is real hub-login TOTP (hub#473). It reads/writes the hub.db \`users\` TOTP
|
|
151
|
+
columns and gates \`/login\`: once enrolled, signing in requires a 6-digit code
|
|
152
|
+
from your authenticator app (or a single-use backup code) after your password.
|
|
153
|
+
|
|
154
|
+
\`parachute auth 2fa enroll\` is headless-friendly — it prints the \`otpauth://\`
|
|
155
|
+
URI and the base32 secret as text so you can type them into an authenticator
|
|
156
|
+
manually (no QR scan needed on a server), then prompts for the current 6-digit
|
|
157
|
+
code to confirm. On success it prints 10 single-use backup codes ONCE — save
|
|
158
|
+
them. The browser path (\`<hub-origin>/account/2fa\`) shows a scannable QR code.
|
|
133
159
|
|
|
134
|
-
|
|
160
|
+
\`parachute auth 2fa disenroll\` clears the TOTP secret + backup codes.
|
|
161
|
+
|
|
162
|
+
In single-user mode both default to the only hub account; pass --username to
|
|
163
|
+
target a specific account when more than one exists.
|
|
135
164
|
|
|
136
165
|
rotate-key generates a fresh RSA-2048 keypair and retires the previous
|
|
137
166
|
one. The retired key keeps appearing in /.well-known/jwks.json for 24
|
|
@@ -1124,6 +1153,115 @@ async function runRevokeToken(args: readonly string[], deps: AuthDeps): Promise<
|
|
|
1124
1153
|
}
|
|
1125
1154
|
}
|
|
1126
1155
|
|
|
1156
|
+
/**
|
|
1157
|
+
* Real hub-login TOTP 2FA CLI (hub#473). `parachute auth 2fa [status|enroll|
|
|
1158
|
+
* disenroll]`. Reads/writes the hub.db `users` TOTP columns — the same store
|
|
1159
|
+
* the `/login` flow and `/account/2fa` browser surface use, so a CLI enroll
|
|
1160
|
+
* gates the exposed hub login exactly like the browser one.
|
|
1161
|
+
*
|
|
1162
|
+
* Headless-first by design: `enroll` prints the `otpauth://` URI + base32
|
|
1163
|
+
* secret as text so the operator can type them into an authenticator app
|
|
1164
|
+
* manually (no QR scan on a server), then prompts for the current code to
|
|
1165
|
+
* confirm before persisting. Browser enroll (QR) lives at
|
|
1166
|
+
* `<hub-origin>/account/2fa`.
|
|
1167
|
+
*/
|
|
1168
|
+
async function run2fa(args: readonly string[], deps: AuthDeps): Promise<number> {
|
|
1169
|
+
const flag = extractUsernameFlag(args);
|
|
1170
|
+
if (flag.error) {
|
|
1171
|
+
console.error(`parachute auth 2fa: ${flag.error}`);
|
|
1172
|
+
return 1;
|
|
1173
|
+
}
|
|
1174
|
+
const sub = flag.rest[0] ?? "status";
|
|
1175
|
+
if (flag.rest.length > 1) {
|
|
1176
|
+
console.error(`parachute auth 2fa: unexpected argument "${flag.rest[1]}"`);
|
|
1177
|
+
console.error("usage: parachute auth 2fa [status|enroll|disenroll] [--username <name>]");
|
|
1178
|
+
return 1;
|
|
1179
|
+
}
|
|
1180
|
+
if (sub !== "status" && sub !== "enroll" && sub !== "disenroll") {
|
|
1181
|
+
console.error(`parachute auth 2fa: unknown subcommand "${sub}"`);
|
|
1182
|
+
console.error("usage: parachute auth 2fa [status|enroll|disenroll] [--username <name>]");
|
|
1183
|
+
return 1;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
1187
|
+
try {
|
|
1188
|
+
const target = resolveTargetUser(db, flag.username, sub);
|
|
1189
|
+
if ("error" in target) {
|
|
1190
|
+
console.error(`parachute auth 2fa: ${target.error}`);
|
|
1191
|
+
return 1;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (sub === "status") {
|
|
1195
|
+
const state = getTotpState(db, target.id);
|
|
1196
|
+
if (state.secret) {
|
|
1197
|
+
console.log(`Two-factor authentication: ON for "${target.username}".`);
|
|
1198
|
+
if (state.enrolledAt) console.log(` enrolled_at: ${state.enrolledAt}`);
|
|
1199
|
+
console.log(` backup_codes: ${state.backupCodes.length} remaining`);
|
|
1200
|
+
} else {
|
|
1201
|
+
console.log(`Two-factor authentication: OFF for "${target.username}".`);
|
|
1202
|
+
console.log("Run `parachute auth 2fa enroll` to turn it on.");
|
|
1203
|
+
}
|
|
1204
|
+
return 0;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (sub === "disenroll") {
|
|
1208
|
+
if (!isTotpEnrolled(db, target.id)) {
|
|
1209
|
+
console.log(`Two-factor authentication is already off for "${target.username}".`);
|
|
1210
|
+
return 0;
|
|
1211
|
+
}
|
|
1212
|
+
clearEnrollment(db, target.id);
|
|
1213
|
+
console.log(`Turned off two-factor authentication for "${target.username}".`);
|
|
1214
|
+
return 0;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// sub === "enroll"
|
|
1218
|
+
if (isTotpEnrolled(db, target.id)) {
|
|
1219
|
+
console.error(
|
|
1220
|
+
`parachute auth 2fa: two-factor is already enabled for "${target.username}". Run \`parachute auth 2fa disenroll\` first to re-enroll.`,
|
|
1221
|
+
);
|
|
1222
|
+
return 1;
|
|
1223
|
+
}
|
|
1224
|
+
const isInteractive = (deps.isInteractive ?? defaultIsInteractive)();
|
|
1225
|
+
if (!isInteractive) {
|
|
1226
|
+
console.error(
|
|
1227
|
+
"parachute auth 2fa enroll: a TTY is required to confirm the enrollment code (run it interactively)",
|
|
1228
|
+
);
|
|
1229
|
+
return 1;
|
|
1230
|
+
}
|
|
1231
|
+
const readLine = deps.readLine ?? defaultReadLine;
|
|
1232
|
+
|
|
1233
|
+
const { secret } = generateTotpSecret(target.username);
|
|
1234
|
+
const otpauthUrl = otpauthUrlFor(secret, target.username);
|
|
1235
|
+
console.log(`Enrolling two-factor authentication for "${target.username}".`);
|
|
1236
|
+
console.log("");
|
|
1237
|
+
console.log("Add this account to your authenticator app. Either scan the otpauth URL");
|
|
1238
|
+
console.log("(paste it into a QR generator), or enter the secret key manually:");
|
|
1239
|
+
console.log("");
|
|
1240
|
+
console.log(` otpauth URL: ${otpauthUrl}`);
|
|
1241
|
+
console.log(` secret key: ${secret}`);
|
|
1242
|
+
console.log("");
|
|
1243
|
+
const code = (await readLine("Enter the 6-digit code from your app to confirm: ")).trim();
|
|
1244
|
+
if (!verifyTotpCode(secret, code)) {
|
|
1245
|
+
console.error(
|
|
1246
|
+
"parachute auth 2fa enroll: that code didn't match — nothing was saved. Check your device clock and try again.",
|
|
1247
|
+
);
|
|
1248
|
+
return 1;
|
|
1249
|
+
}
|
|
1250
|
+
const result = await persistEnrollment(db, target.id, secret);
|
|
1251
|
+
console.log("");
|
|
1252
|
+
console.log("✓ Two-factor authentication is now ON for hub login.");
|
|
1253
|
+
console.log("");
|
|
1254
|
+
console.log("Save these backup codes — each works once if you lose your authenticator:");
|
|
1255
|
+
console.log("");
|
|
1256
|
+
for (const c of result.backupCodes) console.log(` ${c}`);
|
|
1257
|
+
console.log("");
|
|
1258
|
+
console.log("They are shown only once. Store them somewhere safe.");
|
|
1259
|
+
return 0;
|
|
1260
|
+
} finally {
|
|
1261
|
+
db.close();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1127
1265
|
function runListUsers(deps: AuthDeps): number {
|
|
1128
1266
|
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
1129
1267
|
try {
|
|
@@ -1182,6 +1320,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1182
1320
|
return 1;
|
|
1183
1321
|
}
|
|
1184
1322
|
}
|
|
1323
|
+
if (sub === "2fa") {
|
|
1324
|
+
try {
|
|
1325
|
+
return await run2fa(args.slice(1), normalized);
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1328
|
+
console.error(`parachute auth 2fa: ${msg}`);
|
|
1329
|
+
return 1;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1185
1332
|
if (sub === "list-users") {
|
|
1186
1333
|
return runListUsers(normalized);
|
|
1187
1334
|
}
|
|
@@ -1250,23 +1397,17 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
1250
1397
|
}
|
|
1251
1398
|
}
|
|
1252
1399
|
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1400
|
+
// No subcommands forward to parachute-vault anymore (VAULT_FORWARDED_SUBCOMMANDS
|
|
1401
|
+
// is empty — `2fa` is now hub-local + honest, see #473). Anything that fell
|
|
1402
|
+
// through every hub-local handler above is unknown.
|
|
1403
|
+
if (VAULT_FORWARDED_SUBCOMMANDS.has(sub)) {
|
|
1404
|
+
// Defensive: if a future subcommand is added back to the forward set, route
|
|
1405
|
+
// it. Currently unreachable (the set is empty).
|
|
1259
1406
|
return await runner.run(["parachute-vault", ...args]);
|
|
1260
|
-
} catch (err) {
|
|
1261
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1262
|
-
if (msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found")) {
|
|
1263
|
-
console.error("parachute-vault not found on PATH.");
|
|
1264
|
-
console.error("Install it with: parachute install vault");
|
|
1265
|
-
return 127;
|
|
1266
|
-
}
|
|
1267
|
-
console.error(`failed to run parachute-vault: ${msg}`);
|
|
1268
|
-
return 1;
|
|
1269
1407
|
}
|
|
1408
|
+
console.error(`parachute auth: unknown subcommand "${sub}"`);
|
|
1409
|
+
console.error("run `parachute auth --help` for usage");
|
|
1410
|
+
return 1;
|
|
1270
1411
|
}
|
|
1271
1412
|
|
|
1272
1413
|
// Re-exported so `users.ts` consumers can preserve the named-export.
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public-exposure
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Public-exposure security warning (#186). Once the operator brings up
|
|
3
|
+
* cloudflare or Tailscale Funnel, `/login` is reachable from the public
|
|
4
|
+
* internet on every layer admitting traffic. After #188's `/login` rate-limit
|
|
5
|
+
* floor, the owner password is the wall — and now (hub#473) hub-login TOTP 2FA
|
|
6
|
+
* is the second wall.
|
|
7
|
+
*
|
|
8
|
+
* 2FA at the hub login layer is real as of hub#473: "password +
|
|
9
|
+
* something-you-have." This warning recommends `parachute auth 2fa enroll`
|
|
10
|
+
* (which now gates hub `/login` for real) when the operator hasn't enrolled.
|
|
7
11
|
*
|
|
8
12
|
* Why this is a warning, not a hard gate: hard-gating would surprise operators
|
|
9
13
|
* mid-flow — they ran `parachute expose public` to expose, not to be told
|
|
10
|
-
* "set up 2FA first." A loud, contextual warning + a clear
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* Why the source-of-truth is vault's `config.yaml`: 2FA enrollment lives in
|
|
15
|
-
* `parachute-vault` (the hub forwards `parachute auth 2fa enroll` to vault —
|
|
16
|
-
* see `commands/auth.ts` `VAULT_FORWARDED_SUBCOMMANDS`). The hub's `users`
|
|
17
|
-
* table has no TOTP column today; it will gain one when hub-admin login
|
|
18
|
-
* verifies TOTP against vault. Until then, "is 2FA enrolled?" maps cleanly
|
|
19
|
-
* to "does vault's config.yaml carry a non-empty `totp_secret`?", which is
|
|
20
|
-
* exactly what `readVaultAuthStatus().hasTotp` returns.
|
|
14
|
+
* "set up 2FA first." A loud, contextual warning + a clear remediation is the
|
|
15
|
+
* right shape; the operator decides whether to act now or later. The tunnel is
|
|
16
|
+
* up regardless.
|
|
21
17
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `parachute auth 2fa enroll` then surfaces vault's "install vault first"
|
|
26
|
-
* error, which is the right next step regardless.
|
|
18
|
+
* The probe consults `readVaultAuthStatus().hasTotp`, which now reflects the
|
|
19
|
+
* hub.db `users.totp_secret` column (real hub-login 2FA) — true when any user
|
|
20
|
+
* has enrolled. The warning fires only when no second factor is configured.
|
|
27
21
|
*/
|
|
28
22
|
|
|
29
23
|
import { type VaultAuthStatus, readVaultAuthStatus } from "../vault/auth-status.ts";
|
|
@@ -40,17 +34,20 @@ export interface Public2FAWarningOpts {
|
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
/**
|
|
43
|
-
* `true` when
|
|
44
|
-
* `
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* module-level doc comment.
|
|
37
|
+
* `true` when a second factor is configured, `false` otherwise. As of hub#473
|
|
38
|
+
* this reflects the hub.db `users.totp_secret` column (real hub-login 2FA),
|
|
39
|
+
* with the legacy vault `config.yaml` `totp_secret` as a fallback for old
|
|
40
|
+
* installs — see {@link readVaultAuthStatus} and the module-level doc comment.
|
|
48
41
|
*/
|
|
49
42
|
export function is2FAEnrolled(
|
|
50
|
-
opts: { vaultHome?: string; status?: VaultAuthStatus } = {},
|
|
43
|
+
opts: { vaultHome?: string; hubDbPath?: string; status?: VaultAuthStatus } = {},
|
|
51
44
|
): boolean {
|
|
52
45
|
const status =
|
|
53
|
-
opts.status ??
|
|
46
|
+
opts.status ??
|
|
47
|
+
readVaultAuthStatus({
|
|
48
|
+
...(opts.vaultHome ? { vaultHome: opts.vaultHome } : {}),
|
|
49
|
+
...(opts.hubDbPath ? { hubDbPath: opts.hubDbPath } : {}),
|
|
50
|
+
});
|
|
54
51
|
return status.hasTotp;
|
|
55
52
|
}
|
|
56
53
|
|
|
@@ -71,12 +68,17 @@ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
|
|
|
71
68
|
return false;
|
|
72
69
|
}
|
|
73
70
|
log("");
|
|
74
|
-
log("⚠
|
|
75
|
-
log(` (${opts.publicUrl}/login). Anyone who guesses your password
|
|
76
|
-
log("
|
|
71
|
+
log("⚠ /login is now reachable on the public internet");
|
|
72
|
+
log(` (${opts.publicUrl}/login). Anyone who guesses your password is in.`);
|
|
73
|
+
log("");
|
|
74
|
+
log(" Turn on two-factor authentication — it adds a second wall (a one-time");
|
|
75
|
+
log(" code from your authenticator app) on top of your password:");
|
|
77
76
|
log("");
|
|
78
77
|
log(" parachute auth 2fa enroll");
|
|
79
78
|
log("");
|
|
80
|
-
log("
|
|
79
|
+
log(" (Or set it up in the browser at /account/2fa for a scannable QR code.)");
|
|
80
|
+
log(" Either way, also make sure your owner password is a strong one:");
|
|
81
|
+
log("");
|
|
82
|
+
log(" parachute auth set-password");
|
|
81
83
|
return true;
|
|
82
84
|
}
|
|
@@ -2,20 +2,34 @@
|
|
|
2
2
|
* Post-exposure auth nudge. Runs after `parachute expose public` successfully
|
|
3
3
|
* brings a tunnel up (TTY only). The tunnel is already live; this is purely
|
|
4
4
|
* advisory — we never error the exposure flow regardless of what the user
|
|
5
|
-
* chooses. The goal is to catch the "fresh vault, just went public, no
|
|
6
|
-
*
|
|
5
|
+
* chooses. The goal is to catch the "fresh vault, just went public, no auth
|
|
6
|
+
* configured" trap before someone else finds it first.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* The load-bearing signal is the **owner password**. Post-pvt_*-DROP (vault
|
|
9
|
+
* #412 / hub#466), the vault `tokens` table holds only vestigial pvt_* rows;
|
|
10
|
+
* a non-zero count no longer means "API auth is configured." Access is now
|
|
11
|
+
* hub-issued JWTs, minted against the operator's identity — and minting that
|
|
12
|
+
* identity requires the owner password (browser OAuth) or the operator token
|
|
13
|
+
* that `set-password` seeds. So "has an owner password" is the single gate
|
|
14
|
+
* that tells us whether *any* authenticated access is reachable. We branch
|
|
15
|
+
* purely on password + 2FA; we no longer count vault-DB rows for the auth
|
|
16
|
+
* decision.
|
|
9
17
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* Two states we branch on, based on {@link VaultAuthStatus}:
|
|
19
|
+
*
|
|
20
|
+
* - no owner password: loud warning — the exposure is wide open. Offer to
|
|
21
|
+
* set a password, and point at the hub-JWT mint path for clients.
|
|
22
|
+
* - password set, no 2FA: one-line "looks good" + offer to enroll hub-login
|
|
23
|
+
* TOTP (real as of hub#473) since the box is now public.
|
|
24
|
+
* - password + 2FA set: one-line "looks good, 2FA on."
|
|
25
|
+
*
|
|
26
|
+
* `parachute auth 2fa enroll` is the real hub-login TOTP path now (hub#473) —
|
|
27
|
+
* it gates `/login` for real, so the preflight offers it when the operator has
|
|
28
|
+
* a password but no second factor.
|
|
16
29
|
*
|
|
17
30
|
* Defaults are always "skip" — Enter declines every prompt. User can always
|
|
18
|
-
* run `parachute auth
|
|
31
|
+
* run `parachute auth set-password` / `parachute auth 2fa enroll` /
|
|
32
|
+
* `parachute auth mint-token …` later.
|
|
19
33
|
*/
|
|
20
34
|
|
|
21
35
|
import { createInterface } from "node:readline/promises";
|
|
@@ -100,16 +114,47 @@ async function offerOwnerPassword(r: Resolved): Promise<void> {
|
|
|
100
114
|
}
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Offer to enroll hub-login TOTP 2FA (real as of hub#473). Interactive enroll
|
|
119
|
+
* needs to print a secret + prompt for a confirm code, so we run the real CLI
|
|
120
|
+
* command inheriting stdio. Declining is fine — the operator can run it later.
|
|
121
|
+
*/
|
|
103
122
|
async function offerTotp(r: Resolved): Promise<void> {
|
|
104
|
-
|
|
123
|
+
r.log("");
|
|
124
|
+
r.log("Add two-factor authentication? It puts a one-time code (from your");
|
|
125
|
+
r.log("authenticator app) in front of /login on top of your password.");
|
|
126
|
+
if (await yesNo(r, "Set up two-factor authentication now?")) {
|
|
105
127
|
await runCmd(r, ["parachute", "auth", "2fa", "enroll"], "parachute auth 2fa enroll");
|
|
128
|
+
} else {
|
|
129
|
+
r.log("");
|
|
130
|
+
r.log("You can enroll later: `parachute auth 2fa enroll` (or /account/2fa in a browser).");
|
|
106
131
|
}
|
|
107
132
|
}
|
|
108
133
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
/** One-line confirmation that 2FA is already on. */
|
|
135
|
+
function note2faOn(r: Resolved): void {
|
|
136
|
+
r.log("✓ Two-factor authentication is on.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Programmatic / headless clients don't use a password — they carry a
|
|
141
|
+
* hub-issued JWT. We don't auto-mint one here (it needs a scope, and the
|
|
142
|
+
* operator should choose read vs write per client), so this is guidance,
|
|
143
|
+
* not a prompt. Mint paths, in order of how most operators reach them:
|
|
144
|
+
*
|
|
145
|
+
* - Admin SPA → Vaults → "Connect" card (mints + shows the header command).
|
|
146
|
+
* - `parachute auth mint-token --scope vault:<name>:<verb>` (pipeable JWT).
|
|
147
|
+
*
|
|
148
|
+
* The old affordance ran `parachute vault tokens create`, which exits 1
|
|
149
|
+
* post-DROP (vault no longer mints pvt_* tokens) — we never offer it.
|
|
150
|
+
*/
|
|
151
|
+
function printTokenGuidance(r: Resolved): void {
|
|
152
|
+
const name = r.status.vaultNames[0] ?? "<name>";
|
|
153
|
+
r.log("");
|
|
154
|
+
r.log("For programmatic / headless clients (scripts, CI), mint a hub token:");
|
|
155
|
+
r.log(" • Admin → Vaults → Connect (mints a scope-narrow token + copy-paste header)");
|
|
156
|
+
r.log(` • parachute auth mint-token --scope vault:${name}:read # or :write`);
|
|
157
|
+
r.log(" → attach the printed JWT as Authorization: Bearer <hub-jwt>");
|
|
113
158
|
}
|
|
114
159
|
|
|
115
160
|
function printDivider(r: Resolved): void {
|
|
@@ -118,86 +163,58 @@ function printDivider(r: Resolved): void {
|
|
|
118
163
|
}
|
|
119
164
|
|
|
120
165
|
/**
|
|
121
|
-
* `
|
|
122
|
-
*
|
|
166
|
+
* `no owner password`: the exposure is wide open — without a password,
|
|
167
|
+
* nobody can sign in and no hub JWT can be minted, so there's no auth gate
|
|
168
|
+
* at all. The loudest warning we draw.
|
|
123
169
|
*/
|
|
124
170
|
async function handleWideOpen(r: Resolved): Promise<void> {
|
|
125
171
|
printDivider(r);
|
|
126
|
-
r.log("⚠ No owner password
|
|
172
|
+
r.log("⚠ No owner password is configured.");
|
|
127
173
|
r.log(" The tunnel is reachable from the public internet RIGHT NOW.");
|
|
128
174
|
r.log(" Anyone with the URL can make requests until you set auth up.");
|
|
129
175
|
r.log("");
|
|
130
|
-
r.log("Recommended: set an owner password
|
|
131
|
-
r.log("and
|
|
176
|
+
r.log("Recommended: set an owner password — it's the gate for both browser");
|
|
177
|
+
r.log("sign-in (OAuth) and minting hub tokens for programmatic clients.");
|
|
132
178
|
r.log("");
|
|
133
179
|
await offerOwnerPassword(r);
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
180
|
+
// Programmatic-client guidance is informational (no auto-mint) — print it
|
|
181
|
+
// so the operator knows the headless path exists, not the dead pvt_* one.
|
|
182
|
+
printTokenGuidance(r);
|
|
183
|
+
// Offer real hub-login 2FA (hub#473) — the box is public now.
|
|
137
184
|
await offerTotp(r);
|
|
138
|
-
await offerTokenCreate(r);
|
|
139
185
|
printDivider(r);
|
|
140
186
|
}
|
|
141
187
|
|
|
142
188
|
/**
|
|
143
|
-
* `password set, no 2FA`: the
|
|
144
|
-
*
|
|
189
|
+
* `password set, no 2FA`: the operator has a password but no second factor.
|
|
190
|
+
* One-line confirmation, then offer to enroll TOTP since the box is public.
|
|
145
191
|
*/
|
|
146
|
-
async function
|
|
192
|
+
async function handlePasswordSetNo2fa(r: Resolved): Promise<void> {
|
|
147
193
|
r.log("");
|
|
148
194
|
r.log("✓ Owner password is set.");
|
|
149
|
-
r.log(" Consider also enabling 2FA for defense-in-depth.");
|
|
150
195
|
await offerTotp(r);
|
|
151
196
|
}
|
|
152
197
|
|
|
153
198
|
/**
|
|
154
|
-
* `
|
|
155
|
-
* nobody can sign in through a browser — the hub's OAuth flow is dead in
|
|
156
|
-
* the water. Offer to fix.
|
|
199
|
+
* `password + 2FA set`: the operator did everything. Two-line confirmation.
|
|
157
200
|
*/
|
|
158
|
-
|
|
201
|
+
function handleFullyConfigured(r: Resolved): void {
|
|
159
202
|
r.log("");
|
|
160
|
-
r.log("
|
|
161
|
-
r
|
|
162
|
-
await offerOwnerPassword(r);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* `tokenCount === null`: SQLite probe failed (DB missing, locked, schema
|
|
167
|
-
* drift, whatever). Don't guess; don't prompt on token state. Nudge 2FA
|
|
168
|
-
* if we know the password is set, otherwise stay quiet.
|
|
169
|
-
*/
|
|
170
|
-
async function handleUnknownTokens(r: Resolved): Promise<void> {
|
|
171
|
-
r.log("");
|
|
172
|
-
r.log("ℹ Couldn't read vault token state (vault may be locked or offline).");
|
|
173
|
-
r.log(" Run `parachute vault tokens list` to check token config yourself.");
|
|
174
|
-
if (r.status.hasOwnerPassword && !r.status.hasTotp) {
|
|
175
|
-
r.log("");
|
|
176
|
-
r.log(" (While you're here: owner password is set, 2FA is not.)");
|
|
177
|
-
await offerTotp(r);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* `all set`: password + 2FA + at least one token. Keep it tight.
|
|
183
|
-
*/
|
|
184
|
-
function handleAllGood(r: Resolved): void {
|
|
185
|
-
r.log("");
|
|
186
|
-
r.log("✓ Auth config looks good (password + 2FA + API tokens).");
|
|
203
|
+
r.log("✓ Owner password is set.");
|
|
204
|
+
note2faOn(r);
|
|
187
205
|
}
|
|
188
206
|
|
|
189
207
|
/**
|
|
190
208
|
* Pick the branch. Pure function of the status — keeps test coverage trivial.
|
|
209
|
+
*
|
|
210
|
+
* Owner-password-centric since the pvt_* DROP (hub#466): `tokenCount` is no
|
|
211
|
+
* longer consulted. Real hub-login 2FA (hub#473) re-introduces the 2FA branch:
|
|
212
|
+
* three states — wide-open, password-but-no-2FA, fully-configured.
|
|
191
213
|
*/
|
|
192
|
-
function classify(
|
|
193
|
-
s
|
|
194
|
-
)
|
|
195
|
-
|
|
196
|
-
const hasTokens = s.tokenCount > 0;
|
|
197
|
-
if (!s.hasOwnerPassword && !hasTokens) return "wide-open";
|
|
198
|
-
if (!s.hasOwnerPassword && hasTokens) return "tokens-no-password";
|
|
199
|
-
if (s.hasOwnerPassword && !s.hasTotp) return "password-no-totp";
|
|
200
|
-
return "all-good";
|
|
214
|
+
function classify(s: VaultAuthStatus): "wide-open" | "password-no-2fa" | "fully-configured" {
|
|
215
|
+
if (!s.hasOwnerPassword) return "wide-open";
|
|
216
|
+
if (!s.hasTotp) return "password-no-2fa";
|
|
217
|
+
return "fully-configured";
|
|
201
218
|
}
|
|
202
219
|
|
|
203
220
|
export async function runAuthPreflight(opts: AuthPreflightOpts = {}): Promise<void> {
|
|
@@ -206,17 +223,11 @@ export async function runAuthPreflight(opts: AuthPreflightOpts = {}): Promise<vo
|
|
|
206
223
|
case "wide-open":
|
|
207
224
|
await handleWideOpen(r);
|
|
208
225
|
return;
|
|
209
|
-
case "password-no-
|
|
210
|
-
await
|
|
211
|
-
return;
|
|
212
|
-
case "tokens-no-password":
|
|
213
|
-
await handleTokensNoPassword(r);
|
|
214
|
-
return;
|
|
215
|
-
case "unknown-tokens":
|
|
216
|
-
await handleUnknownTokens(r);
|
|
226
|
+
case "password-no-2fa":
|
|
227
|
+
await handlePasswordSetNo2fa(r);
|
|
217
228
|
return;
|
|
218
|
-
case "
|
|
219
|
-
|
|
229
|
+
case "fully-configured":
|
|
230
|
+
handleFullyConfigured(r);
|
|
220
231
|
return;
|
|
221
232
|
}
|
|
222
233
|
}
|