@openparachute/hub 0.5.2 → 0.5.9-rc.6
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/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
package/src/commands/auth.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
83
|
-
|
|
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\`).
|
|
123
|
-
caps at 365d.
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
649
|
-
*
|
|
650
|
-
*
|
|
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>] [--
|
|
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.
|
|
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:
|
|
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
|
|
904
|
+
let used: Awaited<ReturnType<typeof useOperatorTokenWithAutoRotate>>;
|
|
713
905
|
try {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (
|
|
717
|
-
console.error(
|
|
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
|
|
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);
|