@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
@@ -31,6 +31,7 @@ import { promises as fs } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { configDir } from "./config.ts";
33
33
  import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
34
+ import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
34
35
 
35
36
  export const OPERATOR_TOKEN_FILENAME = "operator.token";
36
37
  /** Default operator-token lifetime — 90 days, was 365d through 0.5.7 (#213). */
@@ -462,3 +463,153 @@ export async function useOperatorTokenWithAutoRotate(
462
463
  status: { kind: "rotated" },
463
464
  };
464
465
  }
466
+
467
+ export interface SelfHealOperatorTokenOpts {
468
+ /**
469
+ * The hub's CURRENT issuer (its public origin once exposed). The stale
470
+ * on-disk token is re-minted under this value. Must be the resolved hub
471
+ * origin, never a raw flag — callers pass `r.hubOrigin` from lifecycle.
472
+ */
473
+ issuer: string;
474
+ /** configDir override (where operator.token lives). Defaults to `configDir()`. */
475
+ configDir?: string;
476
+ /** Operator-facing log sink. Defaults to a no-op (silent). */
477
+ log?: (line: string) => void;
478
+ /** Override the JWT-sign clock — tests pin time. Forwarded to `issueOperatorToken`. */
479
+ now?: () => Date;
480
+ }
481
+
482
+ /**
483
+ * Disambiguated outcome of {@link selfHealOperatorTokenIssuer}. Modelled on
484
+ * {@link RotationStatus} — a small discriminated union the caller logs and
485
+ * tests assert.
486
+ *
487
+ * - `absent` — no `operator.token` on disk; nothing to heal.
488
+ * - `fresh` — the token's `iss` already matches the current issuer; the file
489
+ * is left byte-identical (no rewrite, no log).
490
+ * - `rotated` — the token was genuine-but-stale; re-minted under the current
491
+ * issuer with the SAME scope-set + sub. `path` / `expiresAt` / `scopeSet`
492
+ * describe the freshly-written token.
493
+ * - `skipped` — a guard fired; the on-disk file is left untouched. `reason`:
494
+ * - `unverifiable`: the on-disk token's signature did NOT verify against
495
+ * this hub's current keys (bad/unknown/expired kid, jose `exp` failure,
496
+ * or a revoked jti). We must NOT resurrect or trust it — the operator
497
+ * recovers via `parachute auth rotate-operator`.
498
+ * - `aud-mismatch`: the token carries a non-operator audience. A
499
+ * hand-stashed scope-narrow JWT must not be silently re-minted as a
500
+ * full operator token (parallels the {@link useOperatorTokenWithAutoRotate}
501
+ * privilege guard).
502
+ * - `no-sub`: the token lacks a `sub` claim, so we can't re-mint (don't
503
+ * know who it belongs to).
504
+ * - `no-scope-set`: the token lacks (or has an unrecognized) `pa_scope_set`
505
+ * claim. Falling back to a default would widen scope (hub#224 hardening);
506
+ * refuse instead. Operator recovers via explicit rotate-operator.
507
+ * - `issuer-loopback`: the TARGET issuer is loopback. Re-minting to a
508
+ * loopback `iss` would downgrade a good public token; never do it.
509
+ */
510
+ export type OperatorIssuerHealStatus =
511
+ | { kind: "absent" }
512
+ | { kind: "fresh" }
513
+ | { kind: "rotated"; path: string; scopeSet: OperatorScopeSet; expiresAt: string }
514
+ | {
515
+ kind: "skipped";
516
+ reason: "unverifiable" | "aud-mismatch" | "no-sub" | "no-scope-set" | "issuer-loopback";
517
+ };
518
+
519
+ /**
520
+ * Self-heal the operator token's `iss` when the hub's origin changed after
521
+ * the token was minted.
522
+ *
523
+ * The bug this closes (hub#481, same family as the rc.17 Cloudflare 401 P0):
524
+ * `parachute init`/setup mints `~/.parachute/operator.token` stamped with the
525
+ * hub's origin-at-creation-time (`http://127.0.0.1:1939` on a box set up
526
+ * before exposure). After `parachute expose` brings the hub up on a public
527
+ * origin, on-box services validate incoming bearers' `iss` against the hub's
528
+ * CURRENT origin, so the stale-`iss` operator token is rejected on every CLI
529
+ * auth flow with `bearer token invalid — unexpected "iss" claim value`. That
530
+ * breaks `vault create`, `mcp-install`, and `/api/auth/mint-token`. The token
531
+ * is genuine — it just predates the origin change — so re-issuing it under the
532
+ * current issuer (preserving its scope-set + sub) is the right repair.
533
+ *
534
+ * Hooked into `parachute start hub` (parallel to how rc.17 hooked
535
+ * `selfHealVaultHubOrigin` into `start vault`): existing broken deploys
536
+ * self-correct on the next `start/restart hub`.
537
+ *
538
+ * ## Security invariant (reviewer: verify this holds)
539
+ *
540
+ * Re-mint is gated on `validateAccessToken(db, token)` succeeding WITHOUT an
541
+ * `expectedIssuer` argument — i.e. the on-disk token's SIGNATURE verifies
542
+ * against THIS hub's current public keys (by kid) and passes jose's `exp`
543
+ * check and the revocation check, while NOT pinning `iss`. An attacker cannot
544
+ * forge that signature, so the ONLY tokens that can ever be re-minted are ones
545
+ * this hub itself previously minted. There is no path to mint a token from an
546
+ * untrusted/forged input. Further:
547
+ * - scope-set is preserved verbatim (`payload.pa_scope_set`) — no widening;
548
+ * - expired tokens are refused (jose `exp` → `skipped: unverifiable`);
549
+ * - revoked tokens are refused (validateAccessToken's revocation check);
550
+ * - a non-operator audience is refused (`skipped: aud-mismatch`);
551
+ * - a loopback TARGET issuer is refused (`skipped: issuer-loopback`) — never
552
+ * downgrade a good public token back to loopback.
553
+ * This is strictly a re-issue of the hub's own still-valid credential under
554
+ * the hub's own new issuer.
555
+ */
556
+ export async function selfHealOperatorTokenIssuer(
557
+ db: Database,
558
+ opts: SelfHealOperatorTokenOpts,
559
+ ): Promise<OperatorIssuerHealStatus> {
560
+ const dir = opts.configDir ?? configDir();
561
+ const token = await readOperatorTokenFile(dir);
562
+ if (!token) return { kind: "absent" };
563
+
564
+ // Target-issuer loopback guard FIRST. Re-minting to a loopback `iss` would
565
+ // downgrade a good public token to a non-reachable issuer, recreating the
566
+ // exact iss-mismatch this fix prevents (mirrors `isLoopbackOrigin`'s role in
567
+ // vault-hub-origin-env.ts). Never do it — bail before touching the token.
568
+ if (isLoopbackOrigin(opts.issuer)) return { kind: "skipped", reason: "issuer-loopback" };
569
+
570
+ // Verify the on-disk token WITHOUT pinning `iss`: this checks the signature
571
+ // against the hub's current keys (by kid) + jose's `exp` + revocation, but
572
+ // deliberately does NOT reject a stale issuer. A throw here means the token
573
+ // is unverifiable (bad/unknown/expired kid, expired-by-jose, revoked) — we
574
+ // must NOT resurrect or trust it. Leave the disk file untouched; the operator
575
+ // recovers via `parachute auth rotate-operator`.
576
+ let payload: Awaited<ReturnType<typeof validateAccessToken>>["payload"];
577
+ try {
578
+ ({ payload } = await validateAccessToken(db, token));
579
+ } catch {
580
+ return { kind: "skipped", reason: "unverifiable" };
581
+ }
582
+
583
+ // `iss` already current → no-op. Do NOT rewrite the file; it must stay
584
+ // byte-identical so repeated `start hub`s don't churn it.
585
+ if (payload.iss === opts.issuer) return { kind: "fresh" };
586
+
587
+ // `iss` differs → genuine-but-stale. Apply the same provenance guards
588
+ // `useOperatorTokenWithAutoRotate` uses before re-minting.
589
+ if (payload.aud !== OPERATOR_TOKEN_AUDIENCE) {
590
+ return { kind: "skipped", reason: "aud-mismatch" };
591
+ }
592
+ const sub = typeof payload.sub === "string" && payload.sub.length > 0 ? payload.sub : null;
593
+ if (!sub) return { kind: "skipped", reason: "no-sub" };
594
+ const claimedSet = payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM];
595
+ if (!isOperatorScopeSet(claimedSet)) {
596
+ // No recognized scope-set → falling back to a default would widen scope
597
+ // (hub#224). Refuse; never widen.
598
+ return { kind: "skipped", reason: "no-scope-set" };
599
+ }
600
+
601
+ // Re-mint preserving scope-set + sub. `issueOperatorToken` writes the new
602
+ // token to disk atomically (mint → writeOperatorTokenFile).
603
+ const issued = await issueOperatorToken(db, sub, {
604
+ dir,
605
+ issuer: opts.issuer,
606
+ scopeSet: claimedSet,
607
+ ...(opts.now !== undefined ? { now: opts.now } : {}),
608
+ });
609
+ return {
610
+ kind: "rotated",
611
+ path: issued.path,
612
+ scopeSet: issued.scopeSet,
613
+ expiresAt: issued.expiresAt,
614
+ };
615
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pending-login state for the two-step TOTP login (hub#473).
3
+ *
4
+ * When a user with 2FA enrolled posts a correct username+password to `/login`,
5
+ * we do NOT mint a session yet — the user still has to prove the second
6
+ * factor. We stash the half-authenticated state under an opaque token and hand
7
+ * the browser a short-lived `parachute_hub_pending_login` cookie. The user
8
+ * then posts their TOTP / backup code to `/login/2fa`, which looks up the
9
+ * pending state, verifies the factor, and only then mints the real session.
10
+ *
11
+ * Storage: a process-local Map with per-entry expiry — same posture as the
12
+ * rate-limiter (`rate-limit.ts`). Persistence isn't worth a DB write: the
13
+ * window is 5 minutes, and a process restart simply forces the user to
14
+ * re-enter their password (the password POST is cheap to repeat, and losing
15
+ * an in-flight half-login on restart is fine — no security regression). This
16
+ * also avoids a second schema migration for a 5-minute-lived ephemeral row.
17
+ *
18
+ * The token is a 32-byte base64url random (same shape as a session id), so it
19
+ * is unguessable and opaque to the client. It carries no claims — everything
20
+ * is server-side in the Map.
21
+ */
22
+ import { randomBytes } from "node:crypto";
23
+ import { isHttpsRequest } from "./request-protocol.ts";
24
+
25
+ export const PENDING_LOGIN_COOKIE_NAME = "parachute_hub_pending_login";
26
+ /** Pending logins are valid for 5 minutes — long enough to open an
27
+ * authenticator app, short enough to bound a half-authenticated window. */
28
+ export const PENDING_LOGIN_TTL_MS = 5 * 60 * 1000;
29
+
30
+ interface PendingLogin {
31
+ userId: string;
32
+ /** The post-2FA redirect target resolved at password-verify time. */
33
+ next: string;
34
+ /** Absolute expiry (ms epoch). */
35
+ expiresAtMs: number;
36
+ }
37
+
38
+ const pending = new Map<string, PendingLogin>();
39
+
40
+ function gc(nowMs: number): void {
41
+ for (const [token, p] of pending) {
42
+ if (p.expiresAtMs <= nowMs) pending.delete(token);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Create a pending-login entry and return its opaque token. The caller sets
48
+ * the token as a cookie on the "enter your code" response.
49
+ */
50
+ export function createPendingLogin(
51
+ userId: string,
52
+ next: string,
53
+ now: () => Date = () => new Date(),
54
+ ): string {
55
+ const nowMs = now().getTime();
56
+ gc(nowMs);
57
+ const token = randomBytes(32).toString("base64url");
58
+ pending.set(token, { userId, next, expiresAtMs: nowMs + PENDING_LOGIN_TTL_MS });
59
+ return token;
60
+ }
61
+
62
+ /**
63
+ * Resolve a pending-login token to its state, or null if absent/expired.
64
+ * Does NOT consume — the caller consumes only after the second factor
65
+ * verifies (so a failed 2FA attempt can retry against the same pending login
66
+ * without re-entering the password).
67
+ */
68
+ export function getPendingLogin(
69
+ token: string | null,
70
+ now: () => Date = () => new Date(),
71
+ ): { userId: string; next: string } | null {
72
+ if (!token) return null;
73
+ const nowMs = now().getTime();
74
+ gc(nowMs);
75
+ const p = pending.get(token);
76
+ if (!p) return null;
77
+ if (p.expiresAtMs <= nowMs) {
78
+ pending.delete(token);
79
+ return null;
80
+ }
81
+ return { userId: p.userId, next: p.next };
82
+ }
83
+
84
+ /** Delete a pending-login entry (after successful 2FA, or on cancel). Idempotent. */
85
+ export function consumePendingLogin(token: string | null): void {
86
+ if (token) pending.delete(token);
87
+ }
88
+
89
+ /** Test-only: clear all pending logins between cases. */
90
+ export function _resetPendingLogins(): void {
91
+ pending.clear();
92
+ }
93
+
94
+ export function buildPendingLoginCookie(token: string, req: Request): string {
95
+ const parts = [`${PENDING_LOGIN_COOKIE_NAME}=${token}`, "HttpOnly"];
96
+ if (isHttpsRequest(req)) parts.push("Secure");
97
+ // Path=/login so the cookie only rides /login and /login/2fa requests.
98
+ parts.push("SameSite=Lax", "Path=/login", `Max-Age=${Math.floor(PENDING_LOGIN_TTL_MS / 1000)}`);
99
+ return parts.join("; ");
100
+ }
101
+
102
+ export function buildPendingLoginClearCookie(req: Request): string {
103
+ const parts = [`${PENDING_LOGIN_COOKIE_NAME}=`, "HttpOnly"];
104
+ if (isHttpsRequest(req)) parts.push("Secure");
105
+ parts.push("SameSite=Lax", "Path=/login", "Max-Age=0");
106
+ return parts.join("; ");
107
+ }
108
+
109
+ export function parsePendingLoginCookie(cookieHeader: string | null): string | null {
110
+ if (!cookieHeader) return null;
111
+ for (const part of cookieHeader.split(";")) {
112
+ const [name, ...rest] = part.trim().split("=");
113
+ if (name === PENDING_LOGIN_COOKIE_NAME) return rest.join("=");
114
+ }
115
+ return null;
116
+ }