@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.
Files changed (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -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` TOTP enroll/disable/backup-codes.
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(["2fa"]);
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 Show 2FA state
96
- parachute auth 2fa enroll Enable TOTP 2FA (QR + backup codes)
97
- parachute auth 2fa disable Disable 2FA (requires password)
98
- parachute auth 2fa backup-codes Regenerate backup codes
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 forwards to \`parachute-vault\` which still implements TOTP storage. If
132
- you see "not found on PATH", install vault first:
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
- parachute install vault
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
- if (!VAULT_FORWARDED_SUBCOMMANDS.has(sub)) {
1254
- console.error(`parachute auth: unknown subcommand "${sub}"`);
1255
- console.error("run `parachute auth --help` for usage");
1256
- return 1;
1257
- }
1258
- try {
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 2FA-enrollment warning (#186). Lands as the next layer of
3
- * defense after #188's `/login` rate-limit floor: once the operator brings
4
- * up cloudflare or Tailscale Funnel, `/login` is reachable from the public
5
- * internet on every layer admitting traffic. 2FA is the difference between
6
- * "password is the only wall" and "password + something-you-have."
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 one-line
11
- * remediation is the right shape; the operator decides whether to act now or
12
- * later. The tunnel is up regardless.
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
- * If vault isn't installed at all (rare for the cloudflare path — it requires
23
- * a vault entry but possible on the tailnet/funnel path): `hasTotp` comes
24
- * back `false` and the warning still fires. The remediation
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 `totp_secret` is present and non-empty in vault's config.yaml,
44
- * `false` otherwise (missing vault, missing config.yaml, empty value).
45
- *
46
- * Source-of-truth note: TOTP storage is the vault's, not the hub's. See the
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 ?? readVaultAuthStatus(opts.vaultHome ? { vaultHome: opts.vaultHome } : {});
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("⚠ 2FA is not enrolled. /login is now reachable on the public internet");
75
- log(` (${opts.publicUrl}/login). Anyone who guesses your password`);
76
- log(" is in. Strongly recommended:");
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(" Adds TOTP + backup codes. Takes 30 seconds.");
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
- * password or tokens set" trap before someone else finds it first.
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
- * Four states we branch on, based on {@link VaultAuthStatus}:
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
- * - neither password nor tokens: loud warning + offer to set up each.
11
- * - password, no 2FA: shorter "recommend 2FA" nudge.
12
- * - tokens but no password: OAuth isn't set up; offer to add a password.
13
- * - `tokenCount === null`: couldn't read the DB; advisory only, no prompts
14
- * that depend on token state.
15
- * - all set: one-line "looks good" (the quiet path).
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 …` or `parachute vault tokens create` later.
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
- if (await yesNo(r, "Enable TOTP 2FA now?")) {
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
- async function offerTokenCreate(r: Resolved): Promise<void> {
110
- if (await yesNo(r, "Create an API token now?")) {
111
- await runCmd(r, ["parachute", "vault", "tokens", "create"], "parachute vault tokens create");
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
- * `neither password nor tokens`: the exposure is wide open — anyone who
122
- * finds the URL can talk to the vault. The loudest warning we draw.
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 and no API tokens are configured.");
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 (enables the browser sign-in flow)");
131
- r.log("and/or create an API token (for programmatic clients).");
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
- // Offer 2FA regardless of the password step outcome: we can't observe it
135
- // from outside the subprocess, and vault itself will reject a 2fa enroll
136
- // if there's no password yet, surfacing the real error to the user.
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 common case where the user did the obvious
144
- * thing but hasn't opted into the stronger factor yet. Short nudge.
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 handlePasswordNoTotp(r: Resolved): Promise<void> {
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
- * `tokens exist, no password`: vault is authenticated for API clients but
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
- async function handleTokensNoPassword(r: Resolved): Promise<void> {
201
+ function handleFullyConfigured(r: Resolved): void {
159
202
  r.log("");
160
- r.log(" API tokens exist, but no owner password is set.");
161
- r.log(" Browser sign-in (OAuth) won't work until you add one.");
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: VaultAuthStatus,
194
- ): "wide-open" | "password-no-totp" | "tokens-no-password" | "unknown-tokens" | "all-good" {
195
- if (s.tokenCount === null) return "unknown-tokens";
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-totp":
210
- await handlePasswordNoTotp(r);
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 "all-good":
219
- handleAllGood(r);
229
+ case "fully-configured":
230
+ handleFullyConfigured(r);
220
231
  return;
221
232
  }
222
233
  }