@openparachute/hub 0.5.7 → 0.5.10-rc.2

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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  12. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  13. package/src/__tests__/expose.test.ts +2 -2
  14. package/src/__tests__/hub-server.test.ts +526 -67
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/jwt-sign.test.ts +205 -0
  18. package/src/__tests__/module-manifest.test.ts +48 -0
  19. package/src/__tests__/oauth-handlers.test.ts +375 -5
  20. package/src/__tests__/operator-token.test.ts +427 -3
  21. package/src/__tests__/origin-check.test.ts +220 -0
  22. package/src/__tests__/serve.test.ts +100 -0
  23. package/src/__tests__/setup-gate.test.ts +196 -0
  24. package/src/__tests__/status.test.ts +199 -0
  25. package/src/__tests__/supervisor.test.ts +408 -0
  26. package/src/__tests__/upgrade.test.ts +247 -4
  27. package/src/__tests__/well-known.test.ts +69 -0
  28. package/src/admin-clients.ts +139 -0
  29. package/src/admin-handlers.ts +32 -254
  30. package/src/admin-host-admin-token.ts +25 -10
  31. package/src/admin-login-ui.ts +256 -0
  32. package/src/admin-vault-admin-token.ts +1 -1
  33. package/src/api-me.ts +124 -0
  34. package/src/api-mint-token.ts +239 -0
  35. package/src/api-revocation-list.ts +59 -0
  36. package/src/api-revoke-token.ts +153 -0
  37. package/src/api-tokens.ts +224 -0
  38. package/src/cli.ts +28 -0
  39. package/src/commands/auth.ts +408 -51
  40. package/src/commands/expose-2fa-warning.ts +6 -6
  41. package/src/commands/serve.ts +157 -0
  42. package/src/commands/status.ts +74 -10
  43. package/src/commands/upgrade.ts +33 -6
  44. package/src/csrf.ts +6 -3
  45. package/src/help.ts +54 -5
  46. package/src/hub-control.ts +1 -0
  47. package/src/hub-db.ts +63 -0
  48. package/src/hub-server.ts +630 -135
  49. package/src/hub.ts +272 -149
  50. package/src/install-source.ts +291 -0
  51. package/src/jwt-sign.ts +265 -5
  52. package/src/module-manifest.ts +48 -10
  53. package/src/oauth-handlers.ts +238 -54
  54. package/src/oauth-ui.ts +23 -2
  55. package/src/operator-token.ts +349 -18
  56. package/src/origin-check.ts +127 -0
  57. package/src/rate-limit.ts +5 -2
  58. package/src/scope-explanations.ts +33 -2
  59. package/src/sessions.ts +1 -1
  60. package/src/supervisor.ts +359 -0
  61. package/src/well-known.ts +54 -1
  62. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  63. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  64. package/web/ui/dist/index.html +2 -2
  65. package/src/__tests__/admin-config.test.ts +0 -281
  66. package/src/admin-config-ui.ts +0 -534
  67. package/src/admin-config.ts +0 -226
  68. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  69. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -26,12 +26,23 @@ import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
26
26
  import { openHubDb } from "../hub-db.ts";
27
27
  import { deriveHubOrigin } from "../hub-origin.ts";
28
28
  import { inferAudience } from "../jwt-audience.ts";
29
- import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
29
+ import {
30
+ findTokenRowByJti,
31
+ recordTokenMint,
32
+ revokeTokenByJti,
33
+ signAccessToken,
34
+ tokenRowIdentity,
35
+ } from "../jwt-sign.ts";
30
36
  import {
31
37
  OPERATOR_TOKEN_CLIENT_ID,
38
+ OPERATOR_TOKEN_SCOPE_SET_NAMES,
39
+ type OperatorScopeSet,
40
+ OperatorTokenExpiredError,
41
+ isOperatorScopeSet,
32
42
  issueOperatorToken,
33
- readOperatorTokenFile,
43
+ useOperatorTokenWithAutoRotate,
34
44
  } from "../operator-token.ts";
45
+ import { isNonRequestableScope } from "../scope-explanations.ts";
35
46
  import { rotateSigningKey } from "../signing-keys.ts";
36
47
  import {
37
48
  SingleUserModeError,
@@ -61,6 +72,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
61
72
  "list-users",
62
73
  "rotate-operator",
63
74
  "mint-token",
75
+ "revoke-token",
64
76
  "pending-clients",
65
77
  "approve-client",
66
78
  "list-grants",
@@ -79,10 +91,20 @@ Usage:
79
91
  parachute auth 2fa disable Disable 2FA (requires password)
80
92
  parachute auth 2fa backup-codes Regenerate backup codes
81
93
  parachute auth rotate-key Rotate the hub's JWT signing key
82
- parachute auth rotate-operator Mint a fresh ~/.parachute/operator.token
83
- parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]
94
+ parachute auth rotate-operator [--scope-set <set>]
95
+ Mint a fresh ~/.parachute/operator.token
96
+ (set = install|start|expose|auth|vault|admin,
97
+ default admin)
98
+ parachute auth mint-token --scope <scope> [--aud <aud>] [--expires-in <seconds>]
99
+ [--sub <sub>] [--permissions <json>]
84
100
  Mint a scope-narrow JWT against the
85
- operator's identity (stdout = JWT)
101
+ operator's identity (stdout = JWT).
102
+ --ttl <duration> is the deprecated
103
+ alias (use --expires-in seconds).
104
+ parachute auth revoke-token <jti> Mark a registry-row token revoked
105
+ by jti. Idempotent: a re-revoke
106
+ prints the existing revoked_at and
107
+ exits 0.
86
108
  parachute auth pending-clients List OAuth clients awaiting approval
87
109
  parachute auth approve-client <id> Approve a pending OAuth client
88
110
  parachute auth list-grants [--username <name>]
@@ -112,16 +134,59 @@ hours so cached client copies keep validating until their TTL expires.
112
134
  rotate-operator mints a fresh long-lived operator token at
113
135
  ~/.parachute/operator.token (mode 0600). Local CLI tools read this file
114
136
  as their bearer when calling on-box services. set-password also writes
115
- the file on first-run / password reset.
137
+ the file on first-run / password reset. Default lifetime is 90d (was
138
+ 365d through 0.5.7); CLI flows that read the token within 7d of expiry
139
+ auto-rotate it in place, so weekly users never see an expiry surprise.
140
+
141
+ --scope-set chooses how broad the new token is:
142
+ install — install/upgrade modules (vault:read for new-vault discovery)
143
+ start — lifecycle modules (start/stop/restart/status)
144
+ expose — bring tailnet / public exposure layers up and down
145
+ auth — mint hub-issued tokens, manage user accounts
146
+ vault — administer vaults (create / configure / delete)
147
+ admin — superset of all above; pre-#213 default and current default
148
+
149
+ Phase 1 of #213 ships the vocabulary + flag; Phase 2 (separate follow-up)
150
+ wires per-command enforcement so an \`install\`-only token can't, say, run
151
+ \`parachute expose public\`. Until then, --scope-set is a tool the cautious
152
+ operator can opt into without breaking anyone.
116
153
 
117
154
  mint-token issues a single scope-narrow JWT against the operator's
118
155
  identity, signed with the same key as OAuth-issued tokens. Pipeable:
119
156
  \`parachute auth mint-token --scope scribe:transcribe | pbcopy\`. The
120
157
  audience defaults via the same inference rule the OAuth flow uses
121
158
  (named \`vault:<name>:<verb>\` → \`vault.<name>\`, otherwise the first
122
- colon-prefixed scope's namespace, fallback \`hub\`). TTL defaults to 90d,
123
- caps at 365d. Requires a valid ~/.parachute/operator.token (run
124
- \`parachute auth set-password\` or \`rotate-operator\` first).
159
+ colon-prefixed scope's namespace, fallback \`hub\`). Lifetime defaults
160
+ to 90d, caps at 365d.
161
+
162
+ --scope accepts space-separated multi-scope (e.g.
163
+ \`--scope "vault:default:read agent:wovenboulder:invoke"\`).
164
+
165
+ --expires-in is the canonical lifetime flag — integer seconds (e.g.
166
+ \`--expires-in 86400\` for 1 day). The legacy \`--ttl\` flag accepts a
167
+ duration suffix (\`90d\` / \`24h\` / \`30m\` / \`60s\`) and is supported as
168
+ a deprecated alias; passing it emits a one-line stderr deprecation
169
+ notice. \`--ttl\` will be removed in 0.6.0.
170
+
171
+ --permissions accepts a JSON object encoding fine-grained constraints
172
+ beyond OAuth scope (e.g.
173
+ \`--permissions '{"vault":{"default":{"write_tags":["health"]}}}'\`).
174
+ Carried in the JWT as the \`permissions\` claim per the convergence
175
+ section of the auth-architecture research doc.
176
+
177
+ Every mint writes a row to the hub's token registry (one source of
178
+ truth for revocation, admin UI introspection). Requires a valid
179
+ ~/.parachute/operator.token (run \`parachute auth set-password\` or
180
+ \`rotate-operator\` first).
181
+
182
+ revoke-token flips \`revoked_at\` on a registry row by jti. The
183
+ revocation list endpoint
184
+ (\`/.well-known/parachute-revocation.json\`) picks the change up on
185
+ its next 60s poll; resource servers (vault / scribe / agent) on
186
+ scope-guard 0.2.0+ then reject the JWT. Idempotent: re-revoking an
187
+ already-revoked jti prints the existing revoked_at and exits 0.
188
+ Requires \`parachute:host:auth\` on the operator token (the \`auth\`
189
+ or \`admin\` scope-set).
125
190
 
126
191
  pending-clients + approve-client gate /oauth/register against operator
127
192
  approval (closes #74). Self-served DCR registrations land as 'pending'
@@ -392,7 +457,46 @@ async function runSetPassword(args: readonly string[], deps: AuthDeps): Promise<
392
457
  }
393
458
  }
394
459
 
395
- async function runRotateOperator(deps: AuthDeps): Promise<number> {
460
+ interface RotateOperatorFlags {
461
+ scopeSet?: OperatorScopeSet;
462
+ error?: string;
463
+ }
464
+
465
+ function parseRotateOperatorFlags(args: readonly string[]): RotateOperatorFlags {
466
+ let scopeSet: OperatorScopeSet | undefined;
467
+ for (let i = 0; i < args.length; i++) {
468
+ const a = args[i];
469
+ if (a === "--scope-set") {
470
+ const v = args[++i];
471
+ if (!v) return { error: "--scope-set requires a value" };
472
+ if (!isOperatorScopeSet(v)) {
473
+ return {
474
+ error: `--scope-set must be one of ${OPERATOR_TOKEN_SCOPE_SET_NAMES.join("|")}, got "${v}"`,
475
+ };
476
+ }
477
+ scopeSet = v;
478
+ } else if (a?.startsWith("--scope-set=")) {
479
+ const v = a.slice("--scope-set=".length);
480
+ if (!v) return { error: "--scope-set requires a value" };
481
+ if (!isOperatorScopeSet(v)) {
482
+ return {
483
+ error: `--scope-set must be one of ${OPERATOR_TOKEN_SCOPE_SET_NAMES.join("|")}, got "${v}"`,
484
+ };
485
+ }
486
+ scopeSet = v;
487
+ } else {
488
+ return { error: `unknown flag "${a}"` };
489
+ }
490
+ }
491
+ return scopeSet !== undefined ? { scopeSet } : {};
492
+ }
493
+
494
+ async function runRotateOperator(args: readonly string[], deps: AuthDeps): Promise<number> {
495
+ const flags = parseRotateOperatorFlags(args);
496
+ if (flags.error) {
497
+ console.error(`parachute auth rotate-operator: ${flags.error}`);
498
+ return 1;
499
+ }
396
500
  const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
397
501
  try {
398
502
  const users = listUsers(db);
@@ -406,9 +510,11 @@ async function runRotateOperator(deps: AuthDeps): Promise<number> {
406
510
  const issued = await issueOperatorToken(db, owner.id, {
407
511
  dir: deps.configDir,
408
512
  issuer: resolveHubIssuer(deps.hubOrigin, deps.configDir ?? CONFIG_DIR),
513
+ ...(flags.scopeSet !== undefined ? { scopeSet: flags.scopeSet } : {}),
409
514
  });
410
515
  console.log("Rotated operator token.");
411
516
  console.log(` user: ${owner.username}`);
517
+ console.log(` scope_set: ${issued.scopeSet}`);
412
518
  console.log(` path: ${issued.path}`);
413
519
  console.log(` expires_at: ${issued.expiresAt}`);
414
520
  console.log(
@@ -594,7 +700,11 @@ interface MintTokenFlags {
594
700
  scope?: string;
595
701
  aud?: string;
596
702
  ttl?: string;
703
+ expiresIn?: string;
597
704
  sub?: string;
705
+ permissions?: string;
706
+ /** True when --ttl was used (deprecated alias). Triggers a one-line stderr warning. */
707
+ ttlDeprecationSeen?: boolean;
598
708
  error?: string;
599
709
  }
600
710
 
@@ -602,7 +712,10 @@ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
602
712
  let scope: string | undefined;
603
713
  let aud: string | undefined;
604
714
  let ttl: string | undefined;
715
+ let expiresIn: string | undefined;
605
716
  let sub: string | undefined;
717
+ let permissions: string | undefined;
718
+ let ttlDeprecationSeen = false;
606
719
  for (let i = 0; i < args.length; i++) {
607
720
  const a = args[i];
608
721
  if (a === "--scope") {
@@ -623,9 +736,18 @@ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
623
736
  const v = args[++i];
624
737
  if (!v) return { error: "--ttl requires a value" };
625
738
  ttl = v;
739
+ ttlDeprecationSeen = true;
626
740
  } else if (a?.startsWith("--ttl=")) {
627
741
  ttl = a.slice("--ttl=".length);
628
742
  if (!ttl) return { error: "--ttl requires a value" };
743
+ ttlDeprecationSeen = true;
744
+ } else if (a === "--expires-in") {
745
+ const v = args[++i];
746
+ if (!v) return { error: "--expires-in requires a value" };
747
+ expiresIn = v;
748
+ } else if (a?.startsWith("--expires-in=")) {
749
+ expiresIn = a.slice("--expires-in=".length);
750
+ if (!expiresIn) return { error: "--expires-in requires a value" };
629
751
  } else if (a === "--sub") {
630
752
  const v = args[++i];
631
753
  if (!v) return { error: "--sub requires a value" };
@@ -633,11 +755,21 @@ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
633
755
  } else if (a?.startsWith("--sub=")) {
634
756
  sub = a.slice("--sub=".length);
635
757
  if (!sub) return { error: "--sub requires a value" };
758
+ } else if (a === "--permissions") {
759
+ const v = args[++i];
760
+ if (!v) return { error: "--permissions requires a value" };
761
+ permissions = v;
762
+ } else if (a?.startsWith("--permissions=")) {
763
+ permissions = a.slice("--permissions=".length);
764
+ if (!permissions) return { error: "--permissions requires a value" };
636
765
  } else {
637
766
  return { error: `unknown flag "${a}"` };
638
767
  }
639
768
  }
640
- return { scope, aud, ttl, sub };
769
+ if (ttl !== undefined && expiresIn !== undefined) {
770
+ return { error: "pass --expires-in OR --ttl, not both (--ttl is the deprecated alias)" };
771
+ }
772
+ return { scope, aud, ttl, expiresIn, sub, permissions, ttlDeprecationSeen };
641
773
  }
642
774
 
643
775
  const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
@@ -645,9 +777,9 @@ const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
645
777
 
646
778
  /**
647
779
  * Parse a Go-ish duration string: integer + one of d/h/m/s. Caps at 365d.
648
- * `90d` → 7776000. We don't honor Go's stdlib `time.ParseDuration` exactly
649
- * (no `d` there), so this is a small custom parser to keep the operator
650
- * surface obvious.
780
+ * `90d` → 7776000. Used by the deprecated --ttl alias. The canonical
781
+ * --expires-in flag takes a raw integer seconds value (per OAuth's
782
+ * `expires_in` claim semantics — see `parseExpiresIn`).
651
783
  */
652
784
  function parseTtl(input: string): { seconds: number } | { error: string } {
653
785
  const m = /^(\d+)(d|h|m|s)$/.exec(input);
@@ -663,6 +795,29 @@ function parseTtl(input: string): { seconds: number } | { error: string } {
663
795
  return { seconds };
664
796
  }
665
797
 
798
+ /**
799
+ * Parse the canonical --expires-in flag value as an integer seconds count.
800
+ * Matches OAuth's `expires_in` claim semantics — the JWT `exp` is
801
+ * `iat + expires_in`. Caps at 365d like the deprecated --ttl path.
802
+ */
803
+ function parseExpiresIn(input: string): { seconds: number } | { error: string } {
804
+ if (!/^\d+$/.test(input)) {
805
+ return {
806
+ error: `invalid --expires-in "${input}" — expected an integer seconds count (e.g. 86400 = 1 day)`,
807
+ };
808
+ }
809
+ const seconds = Number.parseInt(input, 10);
810
+ if (!Number.isFinite(seconds) || seconds <= 0) {
811
+ return { error: `invalid --expires-in "${input}" — must be > 0` };
812
+ }
813
+ if (seconds > MINT_TOKEN_TTL_MAX_SECONDS) {
814
+ return {
815
+ error: `--expires-in "${input}" exceeds 365d cap (${MINT_TOKEN_TTL_MAX_SECONDS} seconds)`,
816
+ };
817
+ }
818
+ return { seconds };
819
+ }
820
+
666
821
  async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<number> {
667
822
  const flags = parseMintTokenFlags(args);
668
823
  if (flags.error) {
@@ -672,7 +827,7 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
672
827
  if (!flags.scope) {
673
828
  console.error("parachute auth mint-token: --scope is required");
674
829
  console.error(
675
- "usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]",
830
+ "usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--expires-in <seconds>] [--sub <sub>] [--permissions <json>]",
676
831
  );
677
832
  return 1;
678
833
  }
@@ -683,8 +838,51 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
683
838
  return 1;
684
839
  }
685
840
 
841
+ // Privilege-diffusion guard: mint paths cannot themselves mint tokens
842
+ // carrying non-requestable scopes (parachute:host:admin, the host:*
843
+ // narrow scopes, vault:<name>:admin). Holder of `parachute:host:auth`
844
+ // can mint vault/scribe/agent verb scopes for downstream services, but
845
+ // cannot mint another `:auth` (or any other non-requestable) without
846
+ // forced re-auth via the operator.token rotation path. Same set the
847
+ // public OAuth flow already rejects.
848
+ const blocked = scopes.filter((s) => isNonRequestableScope(s));
849
+ if (blocked.length > 0) {
850
+ console.error(
851
+ `parachute auth mint-token: scope ${blocked.join(", ")} is not requestable via mint-token; use OAuth flow or operator rotation`,
852
+ );
853
+ return 1;
854
+ }
855
+
856
+ let permissions: string | undefined;
857
+ if (flags.permissions !== undefined) {
858
+ try {
859
+ // Parse to validate well-formedness — round-trip through JSON.stringify
860
+ // so we hand the JWT a canonicalized payload (no operator-introduced
861
+ // whitespace, no comments, no trailing commas).
862
+ const parsed = JSON.parse(flags.permissions) as unknown;
863
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
864
+ console.error(
865
+ 'parachute auth mint-token: --permissions must be a JSON object (e.g. \'{"vault":{"default":{"write_tags":["health"]}}}\')',
866
+ );
867
+ return 1;
868
+ }
869
+ permissions = JSON.stringify(parsed);
870
+ } catch (err) {
871
+ const msg = err instanceof Error ? err.message : String(err);
872
+ console.error(`parachute auth mint-token: --permissions is not valid JSON — ${msg}`);
873
+ return 1;
874
+ }
875
+ }
876
+
686
877
  let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
687
- if (flags.ttl) {
878
+ if (flags.expiresIn) {
879
+ const parsed = parseExpiresIn(flags.expiresIn);
880
+ if ("error" in parsed) {
881
+ console.error(`parachute auth mint-token: ${parsed.error}`);
882
+ return 1;
883
+ }
884
+ ttlSeconds = parsed.seconds;
885
+ } else if (flags.ttl) {
688
886
  const parsed = parseTtl(flags.ttl);
689
887
  if ("error" in parsed) {
690
888
  console.error(`parachute auth mint-token: ${parsed.error}`);
@@ -692,49 +890,25 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
692
890
  }
693
891
  ttlSeconds = parsed.seconds;
694
892
  }
695
-
696
- const configDir = deps.configDir ?? CONFIG_DIR;
697
- const operatorToken = await readOperatorTokenFile(configDir);
698
- if (!operatorToken) {
893
+ if (flags.ttlDeprecationSeen) {
699
894
  console.error(
700
- "parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
895
+ "parachute auth mint-token: --ttl is deprecated; use --expires-in <seconds> instead (will be removed in 0.6.0)",
701
896
  );
702
- console.error(
703
- "run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
704
- );
705
- return 1;
706
897
  }
707
898
 
899
+ const configDir = deps.configDir ?? CONFIG_DIR;
708
900
  const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
709
901
 
710
902
  const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
711
903
  try {
712
- let operatorSub: string;
904
+ let used: Awaited<ReturnType<typeof useOperatorTokenWithAutoRotate>>;
713
905
  try {
714
- const validated = await validateAccessToken(db, operatorToken, issuer);
715
- const sub = validated.payload.sub;
716
- if (typeof sub !== "string" || sub.length === 0) {
717
- console.error("parachute auth mint-token: operator token has no sub claim");
718
- return 1;
719
- }
720
- // Scope gate: a valid signature + non-expired JWT at this path is not
721
- // sufficient — the token must carry operator-equivalent scope. Without
722
- // this, a narrowly-scoped JWT stashed at ~/.parachute/operator.token
723
- // would be treated as operator-bearer and mint arbitrary tokens
724
- // (privilege escalation: narrow → arbitrary). Only set-password and
725
- // rotate-operator legitimately write to this path; both seed the full
726
- // OPERATOR_TOKEN_SCOPES set, so hub:admin is the right gate.
727
- const tokenScope =
728
- typeof validated.payload.scope === "string"
729
- ? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
730
- : [];
731
- if (!tokenScope.includes("hub:admin")) {
732
- console.error("parachute auth mint-token: operator token lacks hub:admin scope");
733
- console.error("run `parachute auth rotate-operator` to mint a fresh one");
906
+ used = await useOperatorTokenWithAutoRotate(db, { configDir, issuer });
907
+ } catch (err) {
908
+ if (err instanceof OperatorTokenExpiredError) {
909
+ console.error(`parachute auth mint-token: ${err.message}`);
734
910
  return 1;
735
911
  }
736
- operatorSub = sub;
737
- } catch (err) {
738
912
  const msg = err instanceof Error ? err.message : String(err);
739
913
  console.error(`parachute auth mint-token: operator token invalid — ${msg}`);
740
914
  console.error(
@@ -742,18 +916,79 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
742
916
  );
743
917
  return 1;
744
918
  }
919
+ if (!used) {
920
+ console.error(
921
+ "parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
922
+ );
923
+ console.error(
924
+ "run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
925
+ );
926
+ return 1;
927
+ }
928
+ if (used.rotated) {
929
+ console.error(
930
+ `parachute auth mint-token: operator token within 7d of expiry — auto-rotated to ${used.rotated.expiresAt} (scope_set=${used.rotated.scopeSet})`,
931
+ );
932
+ }
933
+ const operatorSub = used.payload.sub;
934
+ if (typeof operatorSub !== "string" || operatorSub.length === 0) {
935
+ console.error("parachute auth mint-token: operator token has no sub claim");
936
+ return 1;
937
+ }
938
+ // Scope gate: a valid signature + non-expired JWT at this path is not
939
+ // sufficient — the token must carry mint-token authority. Without this,
940
+ // a narrowly-scoped JWT stashed at ~/.parachute/operator.token would be
941
+ // treated as operator-bearer and mint arbitrary tokens (privilege
942
+ // escalation: narrow → arbitrary). Only set-password and rotate-operator
943
+ // legitimately write to this path.
944
+ //
945
+ // Gate is `parachute:host:auth` (was `hub:admin` through 0.5.8-rc.4 — see
946
+ // hub#222). Both the `admin` scope-set (which includes `:host:auth` as a
947
+ // superset) and the `auth` scope-set (which IS `:host:auth`) qualify; the
948
+ // `auth` scope-set was always meant to gate auth surfaces per the #214
949
+ // design, but the CLI mint-token gate was historically narrower than the
950
+ // HTTP equivalent. This brings the two surfaces into alignment.
951
+ const tokenScope =
952
+ typeof used.payload.scope === "string"
953
+ ? used.payload.scope.split(/\s+/).filter((s) => s.length > 0)
954
+ : [];
955
+ if (!tokenScope.includes("parachute:host:auth")) {
956
+ console.error("parachute auth mint-token: operator token lacks parachute:host:auth scope");
957
+ console.error(
958
+ "narrowed scope-sets without `auth` (install/start/expose/vault) can't mint follow-on tokens — run `parachute auth rotate-operator --scope-set auth` (or `admin`) for a token that can",
959
+ );
960
+ return 1;
961
+ }
745
962
 
746
963
  const audience = flags.aud ?? inferAudience(scopes);
747
- const sub = flags.sub ?? operatorSub;
964
+ const subjectForMint = flags.sub ?? operatorSub;
965
+ const permissionsClaim = permissions !== undefined ? JSON.parse(permissions) : undefined;
748
966
 
749
967
  const minted = await signAccessToken(db, {
750
- sub,
968
+ sub: subjectForMint,
751
969
  scopes,
752
970
  audience,
753
971
  clientId: OPERATOR_TOKEN_CLIENT_ID,
754
972
  issuer,
755
973
  ttlSeconds,
974
+ ...(permissionsClaim !== undefined ? { extraClaims: { permissions: permissionsClaim } } : {}),
975
+ });
976
+
977
+ // Write a registry row (hub#212 Phase 1). Powers the revocation list
978
+ // endpoint and admin UI introspection. Per design: CLI-mint rows have
979
+ // user_id NULL; the subject column carries the chosen mint subject
980
+ // (--sub overrides operator-sub). The JWT is its own access token,
981
+ // not a refresh token, so refresh_token_hash + family_id stay NULL.
982
+ recordTokenMint(db, {
983
+ jti: minted.jti,
984
+ createdVia: "cli_mint",
985
+ subject: subjectForMint,
986
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
987
+ scopes,
988
+ expiresAt: minted.expiresAt,
989
+ ...(permissions !== undefined ? { permissions } : {}),
756
990
  });
991
+
757
992
  console.log(minted.token);
758
993
  return 0;
759
994
  } finally {
@@ -761,6 +996,119 @@ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<nu
761
996
  }
762
997
  }
763
998
 
999
+ async function runRevokeToken(args: readonly string[], deps: AuthDeps): Promise<number> {
1000
+ // Single positional: the jti. No flags. (If we grow flags later — say,
1001
+ // --reason for an audit string — they can join here without disrupting
1002
+ // the positional contract.)
1003
+ const positionals = args.filter((a) => !a.startsWith("--"));
1004
+ const flags = args.filter((a) => a.startsWith("--"));
1005
+ if (flags.length > 0) {
1006
+ console.error(
1007
+ `parachute auth revoke-token: unexpected flag "${flags[0]}" (this command takes a jti positional only)`,
1008
+ );
1009
+ return 1;
1010
+ }
1011
+ if (positionals.length === 0) {
1012
+ console.error("parachute auth revoke-token: missing jti argument");
1013
+ console.error("usage: parachute auth revoke-token <jti>");
1014
+ return 1;
1015
+ }
1016
+ if (positionals.length > 1) {
1017
+ console.error(
1018
+ `parachute auth revoke-token: unexpected argument "${positionals[1]}" (only one jti at a time)`,
1019
+ );
1020
+ return 1;
1021
+ }
1022
+ const jti = positionals[0]!;
1023
+
1024
+ const configDir = deps.configDir ?? CONFIG_DIR;
1025
+ const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
1026
+
1027
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
1028
+ try {
1029
+ let used: Awaited<ReturnType<typeof useOperatorTokenWithAutoRotate>>;
1030
+ try {
1031
+ used = await useOperatorTokenWithAutoRotate(db, { configDir, issuer });
1032
+ } catch (err) {
1033
+ if (err instanceof OperatorTokenExpiredError) {
1034
+ console.error(`parachute auth revoke-token: ${err.message}`);
1035
+ return 1;
1036
+ }
1037
+ const msg = err instanceof Error ? err.message : String(err);
1038
+ console.error(`parachute auth revoke-token: operator token invalid — ${msg}`);
1039
+ console.error(
1040
+ "run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
1041
+ );
1042
+ return 1;
1043
+ }
1044
+ if (!used) {
1045
+ console.error(
1046
+ "parachute auth revoke-token: no operator token found at ~/.parachute/operator.token",
1047
+ );
1048
+ console.error(
1049
+ "run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
1050
+ );
1051
+ return 1;
1052
+ }
1053
+ if (used.rotated) {
1054
+ console.error(
1055
+ `parachute auth revoke-token: operator token within 7d of expiry — auto-rotated to ${used.rotated.expiresAt} (scope_set=${used.rotated.scopeSet})`,
1056
+ );
1057
+ }
1058
+ // Scope gate: same as the HTTP /api/auth/revoke-token endpoint will use
1059
+ // (and the same as /api/auth/mint-token uses today). Mirrors the
1060
+ // privilege-shape of mint — both surfaces hand the operator power that
1061
+ // a narrow non-auth scope-set token shouldn't carry. The `auth`
1062
+ // scope-set covers this; so does `admin` (superset).
1063
+ const tokenScope =
1064
+ typeof used.payload.scope === "string"
1065
+ ? used.payload.scope.split(/\s+/).filter((s) => s.length > 0)
1066
+ : [];
1067
+ if (!tokenScope.includes("parachute:host:auth")) {
1068
+ console.error("parachute auth revoke-token: operator token lacks parachute:host:auth scope");
1069
+ console.error(
1070
+ "narrowed scope-sets without `auth` (install/start/expose/vault) can't revoke tokens — run `parachute auth rotate-operator --scope-set auth` (or `admin`) for a token that can",
1071
+ );
1072
+ return 1;
1073
+ }
1074
+
1075
+ const row = findTokenRowByJti(db, jti);
1076
+ if (!row) {
1077
+ console.error(`parachute auth revoke-token: no token with jti ${jti} found in registry`);
1078
+ return 1;
1079
+ }
1080
+ if (row.revokedAt) {
1081
+ // Idempotent re-revoke. Surface the existing timestamp so an operator
1082
+ // who's not sure whether the previous attempt landed gets a clear
1083
+ // confirmation it did.
1084
+ console.log(`already revoked at ${row.revokedAt}: jti=${jti}`);
1085
+ return 0;
1086
+ }
1087
+ const ok = revokeTokenByJti(db, jti, new Date());
1088
+ if (!ok) {
1089
+ // Race: row existed, then disappeared or got revoked between our
1090
+ // lookups. Surface as not-found rather than silently succeeding —
1091
+ // the operator should know nothing changed under their hand.
1092
+ console.error(
1093
+ `parachute auth revoke-token: jti ${jti} could not be revoked (race or concurrent change)`,
1094
+ );
1095
+ return 1;
1096
+ }
1097
+ // Use the canonical `tokenRowIdentity` helper rather than reading
1098
+ // userId/subject inline. The label is `identity=` (not `subject=`)
1099
+ // because for OAuth-issued rows `userId` is the user UUID and the
1100
+ // `subject` column is NULL; calling that field "subject" in the
1101
+ // output would mislabel the UUID for any operator grepping on it.
1102
+ // `identity=` matches what the helper returns regardless of row type.
1103
+ console.log(
1104
+ `revoked: jti=${jti}, identity=${tokenRowIdentity(row)}, scope=${row.scopes.join(" ") || "(none)"}`,
1105
+ );
1106
+ return 0;
1107
+ } finally {
1108
+ db.close();
1109
+ }
1110
+ }
1111
+
764
1112
  function runListUsers(deps: AuthDeps): number {
765
1113
  const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
766
1114
  try {
@@ -824,7 +1172,7 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
824
1172
  }
825
1173
  if (sub === "rotate-operator") {
826
1174
  try {
827
- return await runRotateOperator(normalized);
1175
+ return await runRotateOperator(args.slice(1), normalized);
828
1176
  } catch (err) {
829
1177
  const msg = err instanceof Error ? err.message : String(err);
830
1178
  console.error(`parachute auth rotate-operator: ${msg}`);
@@ -840,6 +1188,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
840
1188
  return 1;
841
1189
  }
842
1190
  }
1191
+ if (sub === "revoke-token") {
1192
+ try {
1193
+ return await runRevokeToken(args.slice(1), normalized);
1194
+ } catch (err) {
1195
+ const msg = err instanceof Error ? err.message : String(err);
1196
+ console.error(`parachute auth revoke-token: ${msg}`);
1197
+ return 1;
1198
+ }
1199
+ }
843
1200
  if (sub === "pending-clients") {
844
1201
  try {
845
1202
  return runPendingClients(normalized);
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Public-exposure 2FA-enrollment warning (#186). Lands as the next layer of
3
- * defense after #188's `/admin/login` rate-limit floor: once the operator
4
- * brings up cloudflare or Tailscale Funnel, `/admin/login` is reachable from
5
- * the public internet on every layer admitting traffic. 2FA is the difference
6
- * between "password is the only wall" and "password + something-you-have."
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."
7
7
  *
8
8
  * Why this is a warning, not a hard gate: hard-gating would surprise operators
9
9
  * mid-flow — they ran `parachute expose public` to expose, not to be told
@@ -71,8 +71,8 @@ export function printPublic2FAWarning(opts: Public2FAWarningOpts): boolean {
71
71
  return false;
72
72
  }
73
73
  log("");
74
- log("⚠ 2FA is not enrolled. /admin is now reachable on the public internet");
75
- log(` (${opts.publicUrl}/admin/login). Anyone who guesses your password`);
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
76
  log(" is in. Strongly recommended:");
77
77
  log("");
78
78
  log(" parachute auth 2fa enroll");