@openparachute/hub 0.3.0-rc.1 → 0.5.1

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 (91) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +1063 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +616 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +851 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +269 -38
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-audience.ts +40 -0
  75. package/src/jwt-sign.ts +275 -0
  76. package/src/module-manifest.ts +435 -0
  77. package/src/oauth-handlers.ts +1175 -0
  78. package/src/oauth-ui.ts +582 -0
  79. package/src/operator-token.ts +129 -0
  80. package/src/providers/detect.ts +97 -0
  81. package/src/scope-explanations.ts +137 -0
  82. package/src/scope-registry.ts +158 -0
  83. package/src/service-spec.ts +270 -97
  84. package/src/services-manifest.ts +57 -1
  85. package/src/sessions.ts +115 -0
  86. package/src/signing-keys.ts +120 -0
  87. package/src/users.ts +144 -0
  88. package/src/well-known.ts +62 -26
  89. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  90. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  91. package/web/ui/dist/index.html +14 -0
@@ -1,14 +1,48 @@
1
1
  /**
2
2
  * `parachute auth` — ecosystem-level identity commands.
3
3
  *
4
- * Identity (password + 2FA) is an ecosystem concern now that the hub owns
5
- * OAuth issuance (Phase 0). The *implementation* still lives in
6
- * parachute-vaultthese commands are thin shell-forwards to the vault
7
- * binary so beta users learn the blessed namespace from day one.
4
+ * Hub-local subcommands (write to `~/.parachute/hub.db`):
5
+ * - `rotate-key` rotate the JWT signing keypair.
6
+ * - `set-password`create or update the hub user's password. *NEW in
7
+ * 0.3.1-rc.2*: this used to forward to `parachute-vault set-password`.
8
+ * The hub now owns identity, so set-password writes to `users` in
9
+ * hub.db. The OAuth endpoints still proxy to vault until PR (c) cuts
10
+ * them over — until then, your vault password is what the OAuth flow
11
+ * sees, while `set-password` seeds the hub-side user that PR (c) will
12
+ * start validating against.
13
+ * - `list-users` — show accounts in `users`.
8
14
  *
9
- * Vault keeps its own `set-password` / `2fa` commands for back-compat.
15
+ * Vault-forwarded subcommands (still implemented in `parachute-vault`):
16
+ * - `2fa` — TOTP enroll/disable/backup-codes.
10
17
  */
11
18
 
19
+ import { join } from "node:path";
20
+ import { createInterface } from "node:readline/promises";
21
+ import { approveClient, getClient, listClientsByStatus } from "../clients.ts";
22
+ import { CONFIG_DIR } from "../config.ts";
23
+ import { readExposeState } from "../expose-state.ts";
24
+ import { listGrantsForUser, revokeGrant } from "../grants.ts";
25
+ import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
26
+ import { openHubDb } from "../hub-db.ts";
27
+ import { deriveHubOrigin } from "../hub-origin.ts";
28
+ import { inferAudience } from "../jwt-audience.ts";
29
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
30
+ import {
31
+ OPERATOR_TOKEN_CLIENT_ID,
32
+ issueOperatorToken,
33
+ readOperatorTokenFile,
34
+ } from "../operator-token.ts";
35
+ import { rotateSigningKey } from "../signing-keys.ts";
36
+ import {
37
+ SingleUserModeError,
38
+ UsernameTakenError,
39
+ createUser,
40
+ getUserByUsername,
41
+ listUsers,
42
+ setPassword,
43
+ userCount,
44
+ } from "../users.ts";
45
+
12
46
  export interface Runner {
13
47
  run(cmd: readonly string[]): Promise<number>;
14
48
  }
@@ -20,36 +54,831 @@ export const defaultRunner: Runner = {
20
54
  },
21
55
  };
22
56
 
23
- const AUTH_SUBCOMMANDS = new Set(["set-password", "2fa"]);
57
+ const VAULT_FORWARDED_SUBCOMMANDS = new Set(["2fa"]);
58
+ const HUB_LOCAL_SUBCOMMANDS = new Set([
59
+ "rotate-key",
60
+ "set-password",
61
+ "list-users",
62
+ "rotate-operator",
63
+ "mint-token",
64
+ "pending-clients",
65
+ "approve-client",
66
+ "list-grants",
67
+ "revoke-grant",
68
+ ]);
24
69
 
25
70
  export function authHelp(): string {
26
71
  return `parachute auth — ecosystem identity commands (password + two-factor authentication)
27
72
 
28
73
  Usage:
29
- parachute auth set-password Set or change the owner password
30
- parachute auth set-password --clear Remove the owner password
31
- parachute auth 2fa status Show 2FA state
32
- parachute auth 2fa enroll Enable TOTP 2FA (QR + backup codes)
33
- parachute auth 2fa disable Disable 2FA (requires password)
34
- parachute auth 2fa backup-codes Regenerate backup codes
74
+ parachute auth set-password [--username <name>] [--password <pw>] [--allow-multi]
75
+ Create or update the hub user's password
76
+ parachute auth list-users Show registered hub accounts
77
+ parachute auth 2fa status Show 2FA state
78
+ parachute auth 2fa enroll Enable TOTP 2FA (QR + backup codes)
79
+ parachute auth 2fa disable Disable 2FA (requires password)
80
+ parachute auth 2fa backup-codes Regenerate backup codes
81
+ 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>]
84
+ Mint a scope-narrow JWT against the
85
+ operator's identity (stdout = JWT)
86
+ parachute auth pending-clients List OAuth clients awaiting approval
87
+ parachute auth approve-client <id> Approve a pending OAuth client
88
+ parachute auth list-grants [--username <name>]
89
+ Show OAuth scope grants on record
90
+ parachute auth revoke-grant <client_id> [--username <name>]
91
+ Forget a granted scope-set so the next
92
+ OAuth flow re-prompts for consent
93
+
94
+ set-password and list-users are hub-local — they read/write
95
+ ~/.parachute/hub.db. set-password is interactive by default (prompts for
96
+ the password twice with hidden input). For scripted use, pass
97
+ \`--password <pw>\` and (for first-run setup) \`--username <name>\`.
98
+
99
+ The default username on first run is "owner" — override with --username.
100
+ Single-user mode is the default; pass --allow-multi to add additional
101
+ accounts beyond the first.
35
102
 
36
- All subcommands forward to \`parachute-vault\` which implements the storage
37
- and crypto. If you see "not found on PATH", install vault first:
103
+ 2fa forwards to \`parachute-vault\` which still implements TOTP storage. If
104
+ you see "not found on PATH", install vault first:
38
105
 
39
106
  parachute install vault
107
+
108
+ rotate-key generates a fresh RSA-2048 keypair and retires the previous
109
+ one. The retired key keeps appearing in /.well-known/jwks.json for 24
110
+ hours so cached client copies keep validating until their TTL expires.
111
+
112
+ rotate-operator mints a fresh long-lived operator token at
113
+ ~/.parachute/operator.token (mode 0600). Local CLI tools read this file
114
+ as their bearer when calling on-box services. set-password also writes
115
+ the file on first-run / password reset.
116
+
117
+ mint-token issues a single scope-narrow JWT against the operator's
118
+ identity, signed with the same key as OAuth-issued tokens. Pipeable:
119
+ \`parachute auth mint-token --scope scribe:transcribe | pbcopy\`. The
120
+ audience defaults via the same inference rule the OAuth flow uses
121
+ (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).
125
+
126
+ pending-clients + approve-client gate /oauth/register against operator
127
+ approval (closes #74). Self-served DCR registrations land as 'pending'
128
+ and cannot OAuth until you run \`parachute auth approve-client <id>\`.
129
+ First-party install flows that present \`Authorization: Bearer
130
+ <operator-token>\` with \`hub:admin\` scope land as 'approved' immediately.
131
+
132
+ list-grants + revoke-grant manage the OAuth consent skip-list (closes
133
+ #75). When you approve a scope-set on the consent screen, the hub
134
+ records it so re-running the same flow goes straight to the auth-code
135
+ redirect — no second consent prompt for scopes you've already approved.
136
+ revoke-grant deletes the row so the next flow shows consent again.
137
+ Existing access tokens are NOT touched by revoke-grant; use
138
+ \`/oauth/revoke\` (or wait for them to expire) to terminate live sessions.
40
139
  `;
41
140
  }
42
141
 
43
- export async function auth(
44
- args: readonly string[],
45
- runner: Runner = defaultRunner,
46
- ): Promise<number> {
142
+ export interface AuthDeps {
143
+ runner?: Runner;
144
+ rotateKey?: () => { kid: string; createdAt: string };
145
+ /** Read a hidden password from the terminal. Tests inject a fixed answer. */
146
+ readPassword?: (prompt: string) => Promise<string>;
147
+ /** Read a non-hidden line — username, confirmations, etc. */
148
+ readLine?: (prompt: string) => Promise<string>;
149
+ /** Whether stdin+stdout are a TTY. Tests force false. */
150
+ isInteractive?: () => boolean;
151
+ /** Override the hub-db path. Tests point at a tmp dir. */
152
+ dbPath?: string;
153
+ /**
154
+ * Override the directory where `operator.token` is written. Defaults to
155
+ * `configDir()` (i.e. `~/.parachute/`). Tests point at a tmp dir.
156
+ */
157
+ configDir?: string;
158
+ /**
159
+ * Override the hub origin written into the operator token's `iss` claim.
160
+ * When unset, derived from `expose-state.json` → hub.port → canonical
161
+ * `http://127.0.0.1:1939`, mirroring the resolution `parachute start` uses
162
+ * for `PARACHUTE_HUB_ORIGIN` so the token's iss matches what services see.
163
+ */
164
+ hubOrigin?: string;
165
+ }
166
+
167
+ /**
168
+ * Resolve the hub origin used as `iss` for operator tokens. Mirrors
169
+ * lifecycle.resolveHubOrigin's order, but falls back to the canonical
170
+ * loopback (`http://127.0.0.1:1939`) instead of `undefined` — operator
171
+ * tokens MUST carry an issuer, and on first-run before any expose has
172
+ * happened the canonical loopback is what services will validate against.
173
+ */
174
+ function resolveHubIssuer(override: string | undefined, configDir: string): string {
175
+ if (override) {
176
+ const fromOverride = deriveHubOrigin({ override });
177
+ if (fromOverride) return fromOverride;
178
+ }
179
+ const state = readExposeState(join(configDir, "expose-state.json"));
180
+ if (state?.hubOrigin) return state.hubOrigin;
181
+ const exposeFqdn = state?.canonicalFqdn;
182
+ return (
183
+ deriveHubOrigin({ exposeFqdn, hubPort: readHubPort(configDir) }) ??
184
+ `http://127.0.0.1:${HUB_DEFAULT_PORT}`
185
+ );
186
+ }
187
+
188
+ function defaultRotateKey(): { kid: string; createdAt: string } {
189
+ const db = openHubDb();
190
+ try {
191
+ const k = rotateSigningKey(db);
192
+ return { kid: k.kid, createdAt: k.createdAt };
193
+ } finally {
194
+ db.close();
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Hidden-input password read using stdin raw mode. Hand-rolled rather than
200
+ * pulling in a prompt library — the surface is small (Enter/Backspace/Ctrl-C)
201
+ * and adding a transitive dep just to hide echo is overkill.
202
+ */
203
+ async function defaultReadPassword(prompt: string): Promise<string> {
204
+ process.stdout.write(prompt);
205
+ return new Promise<string>((resolve, reject) => {
206
+ const stdin = process.stdin;
207
+ let buf = "";
208
+ const teardown = () => {
209
+ stdin.setRawMode(false);
210
+ stdin.pause();
211
+ stdin.removeListener("data", onData);
212
+ };
213
+ const onData = (chunk: Buffer) => {
214
+ const ch = chunk.toString("utf8");
215
+ for (const c of ch) {
216
+ if (c === "\n" || c === "\r" || c === "\u0004") {
217
+ teardown();
218
+ process.stdout.write("\n");
219
+ resolve(buf);
220
+ return;
221
+ }
222
+ if (c === "\u0003") {
223
+ teardown();
224
+ process.stdout.write("\n");
225
+ reject(new Error("interrupted"));
226
+ return;
227
+ }
228
+ if (c === "\u007f" || c === "\b") {
229
+ buf = buf.slice(0, -1);
230
+ continue;
231
+ }
232
+ buf += c;
233
+ }
234
+ };
235
+ stdin.setRawMode(true);
236
+ stdin.resume();
237
+ stdin.on("data", onData);
238
+ });
239
+ }
240
+
241
+ async function defaultReadLine(prompt: string): Promise<string> {
242
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
243
+ try {
244
+ return await rl.question(prompt);
245
+ } finally {
246
+ rl.close();
247
+ }
248
+ }
249
+
250
+ function defaultIsInteractive(): boolean {
251
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
252
+ }
253
+
254
+ interface ParsedFlags {
255
+ username?: string;
256
+ password?: string;
257
+ allowMulti: boolean;
258
+ error?: string;
259
+ }
260
+
261
+ function parseSetPasswordFlags(args: readonly string[]): ParsedFlags {
262
+ let username: string | undefined;
263
+ let password: string | undefined;
264
+ let allowMulti = false;
265
+ for (let i = 0; i < args.length; i++) {
266
+ const a = args[i];
267
+ if (a === "--username") {
268
+ const v = args[++i];
269
+ if (!v) return { allowMulti, error: "--username requires a value" };
270
+ username = v;
271
+ } else if (a?.startsWith("--username=")) {
272
+ username = a.slice("--username=".length);
273
+ if (!username) return { allowMulti, error: "--username requires a value" };
274
+ } else if (a === "--password") {
275
+ const v = args[++i];
276
+ if (!v) return { allowMulti, error: "--password requires a value" };
277
+ password = v;
278
+ } else if (a?.startsWith("--password=")) {
279
+ password = a.slice("--password=".length);
280
+ if (!password) return { allowMulti, error: "--password requires a value" };
281
+ } else if (a === "--allow-multi") {
282
+ allowMulti = true;
283
+ } else {
284
+ return { allowMulti, error: `unknown flag "${a}"` };
285
+ }
286
+ }
287
+ return { username, password, allowMulti };
288
+ }
289
+
290
+ async function runSetPassword(args: readonly string[], deps: AuthDeps): Promise<number> {
291
+ const flags = parseSetPasswordFlags(args);
292
+ if (flags.error) {
293
+ console.error(`parachute auth set-password: ${flags.error}`);
294
+ return 1;
295
+ }
296
+ const isInteractive = (deps.isInteractive ?? defaultIsInteractive)();
297
+ const readPassword = deps.readPassword ?? defaultReadPassword;
298
+ const readLine = deps.readLine ?? defaultReadLine;
299
+
300
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
301
+ try {
302
+ const existing = listUsers(db);
303
+ const existingUser = existing[0];
304
+ const targetUsername = flags.username ?? existingUser?.username ?? "owner";
305
+
306
+ let password = flags.password;
307
+ if (!password) {
308
+ if (!isInteractive) {
309
+ console.error(
310
+ "parachute auth set-password: --password is required when stdin is not a TTY",
311
+ );
312
+ return 1;
313
+ }
314
+ const p1 = await readPassword(`Password for "${targetUsername}": `);
315
+ if (p1.length === 0) {
316
+ console.error("password cannot be empty");
317
+ return 1;
318
+ }
319
+ const p2 = await readPassword("Confirm password: ");
320
+ if (p1 !== p2) {
321
+ console.error("passwords did not match");
322
+ return 1;
323
+ }
324
+ password = p1;
325
+ }
326
+
327
+ if (existingUser) {
328
+ // Update path. If --username supplied AND it doesn't match, that's
329
+ // ambiguous: are they renaming or addressing a new user? In single-user
330
+ // mode we refuse rather than guessing.
331
+ if (flags.username && flags.username !== existingUser.username && !flags.allowMulti) {
332
+ console.error(
333
+ `a user named "${existingUser.username}" already exists. To create another, pass --allow-multi.`,
334
+ );
335
+ return 1;
336
+ }
337
+ const target =
338
+ flags.username && flags.username !== existingUser.username && flags.allowMulti
339
+ ? null
340
+ : existingUser;
341
+ if (target) {
342
+ await setPassword(db, target.id, password);
343
+ console.log(`Updated password for "${target.username}".`);
344
+ const issued = await issueOperatorToken(db, target.id, {
345
+ dir: deps.configDir,
346
+ issuer: resolveHubIssuer(deps.hubOrigin, deps.configDir ?? CONFIG_DIR),
347
+ });
348
+ console.log(`Refreshed operator token at ${issued.path}.`);
349
+ return 0;
350
+ }
351
+ }
352
+
353
+ // Create path (no user exists yet, or --allow-multi for an additional one).
354
+ if (existing.length > 0 && !flags.allowMulti) {
355
+ // Should be unreachable given the existingUser branch above, but keep
356
+ // the explicit guard so a future refactor can't quietly drop it.
357
+ console.error("a user already exists; pass --allow-multi to create another");
358
+ return 1;
359
+ }
360
+
361
+ // For first-run interactive without an explicit --username, confirm.
362
+ if (existing.length === 0 && !flags.username && isInteractive) {
363
+ const answer = (await readLine(`Create the first hub user named "owner"? [Y/n] `)).trim();
364
+ if (answer.length > 0 && !/^y(es)?$/i.test(answer)) {
365
+ console.error("aborted; pass --username <name> to choose a different name");
366
+ return 1;
367
+ }
368
+ }
369
+
370
+ try {
371
+ const u = await createUser(db, targetUsername, password, { allowMulti: flags.allowMulti });
372
+ console.log(`Created hub user "${u.username}" (id=${u.id}).`);
373
+ const issued = await issueOperatorToken(db, u.id, {
374
+ dir: deps.configDir,
375
+ issuer: resolveHubIssuer(deps.hubOrigin, deps.configDir ?? CONFIG_DIR),
376
+ });
377
+ console.log(`Wrote operator token to ${issued.path} (mode 0600).`);
378
+ return 0;
379
+ } catch (err) {
380
+ if (err instanceof SingleUserModeError) {
381
+ console.error(err.message);
382
+ return 1;
383
+ }
384
+ if (err instanceof UsernameTakenError) {
385
+ console.error(err.message);
386
+ return 1;
387
+ }
388
+ throw err;
389
+ }
390
+ } finally {
391
+ db.close();
392
+ }
393
+ }
394
+
395
+ async function runRotateOperator(deps: AuthDeps): Promise<number> {
396
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
397
+ try {
398
+ const users = listUsers(db);
399
+ const owner = users[0];
400
+ if (!owner) {
401
+ console.error(
402
+ "no hub users yet — run `parachute auth set-password` to create the first one before issuing an operator token",
403
+ );
404
+ return 1;
405
+ }
406
+ const issued = await issueOperatorToken(db, owner.id, {
407
+ dir: deps.configDir,
408
+ issuer: resolveHubIssuer(deps.hubOrigin, deps.configDir ?? CONFIG_DIR),
409
+ });
410
+ console.log("Rotated operator token.");
411
+ console.log(` user: ${owner.username}`);
412
+ console.log(` path: ${issued.path}`);
413
+ console.log(` expires_at: ${issued.expiresAt}`);
414
+ console.log(
415
+ "Previous tokens stay valid until they expire — the hub does not revoke them. Treat operator.token like an SSH key.",
416
+ );
417
+ return 0;
418
+ } finally {
419
+ db.close();
420
+ }
421
+ }
422
+
423
+ function runPendingClients(deps: AuthDeps): number {
424
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
425
+ try {
426
+ const pending = listClientsByStatus(db, "pending");
427
+ if (pending.length === 0) {
428
+ console.log("(no pending OAuth clients)");
429
+ return 0;
430
+ }
431
+ console.log("CLIENT_ID NAME REGISTERED");
432
+ for (const c of pending) {
433
+ const id = c.clientId.padEnd(36).slice(0, 36);
434
+ const name = (c.clientName ?? "").padEnd(20).slice(0, 20);
435
+ console.log(`${id} ${name} ${c.registeredAt}`);
436
+ }
437
+ return 0;
438
+ } finally {
439
+ db.close();
440
+ }
441
+ }
442
+
443
+ function runApproveClient(args: readonly string[], deps: AuthDeps): number {
444
+ const clientId = args[0];
445
+ if (!clientId) {
446
+ console.error("parachute auth approve-client: missing client_id argument");
447
+ console.error("usage: parachute auth approve-client <client_id>");
448
+ return 1;
449
+ }
450
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
451
+ try {
452
+ const ok = approveClient(db, clientId);
453
+ if (!ok) {
454
+ console.error(`no OAuth client registered with client_id "${clientId}"`);
455
+ return 1;
456
+ }
457
+ console.log(`Approved OAuth client "${clientId}".`);
458
+ return 0;
459
+ } finally {
460
+ db.close();
461
+ }
462
+ }
463
+
464
+ interface UsernameFlag {
465
+ username?: string;
466
+ rest: string[];
467
+ error?: string;
468
+ }
469
+
470
+ function extractUsernameFlag(args: readonly string[]): UsernameFlag {
471
+ let username: string | undefined;
472
+ const rest: string[] = [];
473
+ for (let i = 0; i < args.length; i++) {
474
+ const a = args[i];
475
+ if (a === "--username") {
476
+ const v = args[++i];
477
+ if (!v) return { rest, error: "--username requires a value" };
478
+ username = v;
479
+ } else if (a?.startsWith("--username=")) {
480
+ username = a.slice("--username=".length);
481
+ if (!username) return { rest, error: "--username requires a value" };
482
+ } else if (a !== undefined) {
483
+ rest.push(a);
484
+ }
485
+ }
486
+ return { username, rest };
487
+ }
488
+
489
+ /**
490
+ * Resolve the user a grant subcommand operates on. Default is "the only hub
491
+ * user" (single-user mode); --username is required when multiple users exist.
492
+ */
493
+ function resolveTargetUser(
494
+ db: ReturnType<typeof openHubDb>,
495
+ flagUsername: string | undefined,
496
+ cmd: string,
497
+ ): { id: string; username: string } | { error: string } {
498
+ if (flagUsername) {
499
+ const u = getUserByUsername(db, flagUsername);
500
+ if (!u) return { error: `no hub user named "${flagUsername}"` };
501
+ return { id: u.id, username: u.username };
502
+ }
503
+ const users = listUsers(db);
504
+ if (users.length === 0)
505
+ return { error: "no hub users yet — run `parachute auth set-password` first" };
506
+ if (users.length > 1) {
507
+ return {
508
+ error: `multiple hub users exist; pass --username <name> to ${cmd} a specific user's grant`,
509
+ };
510
+ }
511
+ const only = users[0]!;
512
+ return { id: only.id, username: only.username };
513
+ }
514
+
515
+ function runListGrants(args: readonly string[], deps: AuthDeps): number {
516
+ const flag = extractUsernameFlag(args);
517
+ if (flag.error) {
518
+ console.error(`parachute auth list-grants: ${flag.error}`);
519
+ return 1;
520
+ }
521
+ if (flag.rest.length > 0) {
522
+ console.error(`parachute auth list-grants: unexpected argument "${flag.rest[0]}"`);
523
+ console.error("usage: parachute auth list-grants [--username <name>]");
524
+ return 1;
525
+ }
526
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
527
+ try {
528
+ const target = resolveTargetUser(db, flag.username, "list");
529
+ if ("error" in target) {
530
+ console.error(`parachute auth list-grants: ${target.error}`);
531
+ return 1;
532
+ }
533
+ const grants = listGrantsForUser(db, target.id);
534
+ if (grants.length === 0) {
535
+ console.log(`(no OAuth grants on record for "${target.username}")`);
536
+ return 0;
537
+ }
538
+ console.log(`OAuth grants for "${target.username}":`);
539
+ console.log(
540
+ "CLIENT_ID NAME GRANTED_AT SCOPES",
541
+ );
542
+ for (const g of grants) {
543
+ const client = getClient(db, g.clientId);
544
+ const id = g.clientId.padEnd(36).slice(0, 36);
545
+ const name = (client?.clientName ?? "").padEnd(20).slice(0, 20);
546
+ const at = g.grantedAt.padEnd(24).slice(0, 24);
547
+ console.log(`${id} ${name} ${at} ${g.scopes.join(" ")}`);
548
+ }
549
+ return 0;
550
+ } finally {
551
+ db.close();
552
+ }
553
+ }
554
+
555
+ function runRevokeGrant(args: readonly string[], deps: AuthDeps): number {
556
+ const flag = extractUsernameFlag(args);
557
+ if (flag.error) {
558
+ console.error(`parachute auth revoke-grant: ${flag.error}`);
559
+ return 1;
560
+ }
561
+ const clientId = flag.rest[0];
562
+ if (!clientId) {
563
+ console.error("parachute auth revoke-grant: missing client_id argument");
564
+ console.error("usage: parachute auth revoke-grant <client_id> [--username <name>]");
565
+ return 1;
566
+ }
567
+ if (flag.rest.length > 1) {
568
+ console.error(`parachute auth revoke-grant: unexpected argument "${flag.rest[1]}"`);
569
+ return 1;
570
+ }
571
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
572
+ try {
573
+ const target = resolveTargetUser(db, flag.username, "revoke");
574
+ if ("error" in target) {
575
+ console.error(`parachute auth revoke-grant: ${target.error}`);
576
+ return 1;
577
+ }
578
+ const removed = revokeGrant(db, target.id, clientId);
579
+ if (!removed) {
580
+ console.error(`no grant on record for "${target.username}" → "${clientId}"`);
581
+ return 1;
582
+ }
583
+ console.log(`Revoked OAuth grant: "${target.username}" → "${clientId}".`);
584
+ console.log(
585
+ "Existing access tokens are unaffected — they expire on their own. The next /oauth/authorize for this client will re-prompt for consent.",
586
+ );
587
+ return 0;
588
+ } finally {
589
+ db.close();
590
+ }
591
+ }
592
+
593
+ interface MintTokenFlags {
594
+ scope?: string;
595
+ aud?: string;
596
+ ttl?: string;
597
+ sub?: string;
598
+ error?: string;
599
+ }
600
+
601
+ function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
602
+ let scope: string | undefined;
603
+ let aud: string | undefined;
604
+ let ttl: string | undefined;
605
+ let sub: string | undefined;
606
+ for (let i = 0; i < args.length; i++) {
607
+ const a = args[i];
608
+ if (a === "--scope") {
609
+ const v = args[++i];
610
+ if (!v) return { error: "--scope requires a value" };
611
+ scope = v;
612
+ } else if (a?.startsWith("--scope=")) {
613
+ scope = a.slice("--scope=".length);
614
+ if (!scope) return { error: "--scope requires a value" };
615
+ } else if (a === "--aud") {
616
+ const v = args[++i];
617
+ if (!v) return { error: "--aud requires a value" };
618
+ aud = v;
619
+ } else if (a?.startsWith("--aud=")) {
620
+ aud = a.slice("--aud=".length);
621
+ if (!aud) return { error: "--aud requires a value" };
622
+ } else if (a === "--ttl") {
623
+ const v = args[++i];
624
+ if (!v) return { error: "--ttl requires a value" };
625
+ ttl = v;
626
+ } else if (a?.startsWith("--ttl=")) {
627
+ ttl = a.slice("--ttl=".length);
628
+ if (!ttl) return { error: "--ttl requires a value" };
629
+ } else if (a === "--sub") {
630
+ const v = args[++i];
631
+ if (!v) return { error: "--sub requires a value" };
632
+ sub = v;
633
+ } else if (a?.startsWith("--sub=")) {
634
+ sub = a.slice("--sub=".length);
635
+ if (!sub) return { error: "--sub requires a value" };
636
+ } else {
637
+ return { error: `unknown flag "${a}"` };
638
+ }
639
+ }
640
+ return { scope, aud, ttl, sub };
641
+ }
642
+
643
+ const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
644
+ const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
645
+
646
+ /**
647
+ * 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.
651
+ */
652
+ function parseTtl(input: string): { seconds: number } | { error: string } {
653
+ const m = /^(\d+)(d|h|m|s)$/.exec(input);
654
+ if (!m) return { error: `invalid --ttl "${input}" — expected e.g. 90d, 24h, 30m, 60s` };
655
+ const n = Number.parseInt(m[1]!, 10);
656
+ if (!Number.isFinite(n) || n <= 0) return { error: `invalid --ttl "${input}" — must be > 0` };
657
+ const unit = m[2]!;
658
+ const mult = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
659
+ const seconds = n * mult;
660
+ if (seconds > MINT_TOKEN_TTL_MAX_SECONDS) {
661
+ return { error: `--ttl "${input}" exceeds 365d cap` };
662
+ }
663
+ return { seconds };
664
+ }
665
+
666
+ async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<number> {
667
+ const flags = parseMintTokenFlags(args);
668
+ if (flags.error) {
669
+ console.error(`parachute auth mint-token: ${flags.error}`);
670
+ return 1;
671
+ }
672
+ if (!flags.scope) {
673
+ console.error("parachute auth mint-token: --scope is required");
674
+ console.error(
675
+ "usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]",
676
+ );
677
+ return 1;
678
+ }
679
+
680
+ const scopes = flags.scope.split(/\s+/).filter((s) => s.length > 0);
681
+ if (scopes.length === 0) {
682
+ console.error("parachute auth mint-token: --scope must contain at least one scope");
683
+ return 1;
684
+ }
685
+
686
+ let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
687
+ if (flags.ttl) {
688
+ const parsed = parseTtl(flags.ttl);
689
+ if ("error" in parsed) {
690
+ console.error(`parachute auth mint-token: ${parsed.error}`);
691
+ return 1;
692
+ }
693
+ ttlSeconds = parsed.seconds;
694
+ }
695
+
696
+ const configDir = deps.configDir ?? CONFIG_DIR;
697
+ const operatorToken = await readOperatorTokenFile(configDir);
698
+ if (!operatorToken) {
699
+ console.error(
700
+ "parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
701
+ );
702
+ console.error(
703
+ "run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
704
+ );
705
+ return 1;
706
+ }
707
+
708
+ const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
709
+
710
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
711
+ try {
712
+ let operatorSub: string;
713
+ 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");
734
+ return 1;
735
+ }
736
+ operatorSub = sub;
737
+ } catch (err) {
738
+ const msg = err instanceof Error ? err.message : String(err);
739
+ console.error(`parachute auth mint-token: operator token invalid — ${msg}`);
740
+ console.error(
741
+ "run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
742
+ );
743
+ return 1;
744
+ }
745
+
746
+ const audience = flags.aud ?? inferAudience(scopes);
747
+ const sub = flags.sub ?? operatorSub;
748
+
749
+ const minted = await signAccessToken(db, {
750
+ sub,
751
+ scopes,
752
+ audience,
753
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
754
+ issuer,
755
+ ttlSeconds,
756
+ });
757
+ console.log(minted.token);
758
+ return 0;
759
+ } finally {
760
+ db.close();
761
+ }
762
+ }
763
+
764
+ function runListUsers(deps: AuthDeps): number {
765
+ const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
766
+ try {
767
+ const users = listUsers(db);
768
+ if (users.length === 0) {
769
+ console.log("(no hub users yet — run `parachute auth set-password` to create the first one)");
770
+ return 0;
771
+ }
772
+ console.log("USERNAME ID CREATED");
773
+ for (const u of users) {
774
+ const username = u.username.padEnd(18).slice(0, 18);
775
+ const id = u.id.padEnd(36).slice(0, 36);
776
+ console.log(`${username} ${id} ${u.createdAt}`);
777
+ }
778
+ return 0;
779
+ } finally {
780
+ db.close();
781
+ }
782
+ }
783
+
784
+ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}): Promise<number> {
785
+ // Back-compat shim: callers used to pass a Runner directly. Detect that
786
+ // shape (a `run` method) and lift it into the new deps bag.
787
+ const normalized: AuthDeps =
788
+ typeof (deps as Runner).run === "function" ? { runner: deps as Runner } : (deps as AuthDeps);
789
+ const runner = normalized.runner ?? defaultRunner;
790
+ const rotateKey = normalized.rotateKey ?? defaultRotateKey;
791
+
47
792
  const sub = args[0];
48
793
  if (sub === undefined || sub === "--help" || sub === "-h" || sub === "help") {
49
794
  console.log(authHelp());
50
795
  return 0;
51
796
  }
52
- if (!AUTH_SUBCOMMANDS.has(sub)) {
797
+
798
+ if (HUB_LOCAL_SUBCOMMANDS.has(sub)) {
799
+ if (sub === "rotate-key") {
800
+ try {
801
+ const { kid, createdAt } = rotateKey();
802
+ console.log("Rotated hub signing key.");
803
+ console.log(` kid: ${kid}`);
804
+ console.log(` created_at: ${createdAt}`);
805
+ console.log("Previous key keeps validating tokens for 24h via /.well-known/jwks.json.");
806
+ return 0;
807
+ } catch (err) {
808
+ const msg = err instanceof Error ? err.message : String(err);
809
+ console.error(`parachute auth rotate-key: ${msg}`);
810
+ return 1;
811
+ }
812
+ }
813
+ if (sub === "set-password") {
814
+ try {
815
+ return await runSetPassword(args.slice(1), normalized);
816
+ } catch (err) {
817
+ const msg = err instanceof Error ? err.message : String(err);
818
+ console.error(`parachute auth set-password: ${msg}`);
819
+ return 1;
820
+ }
821
+ }
822
+ if (sub === "list-users") {
823
+ return runListUsers(normalized);
824
+ }
825
+ if (sub === "rotate-operator") {
826
+ try {
827
+ return await runRotateOperator(normalized);
828
+ } catch (err) {
829
+ const msg = err instanceof Error ? err.message : String(err);
830
+ console.error(`parachute auth rotate-operator: ${msg}`);
831
+ return 1;
832
+ }
833
+ }
834
+ if (sub === "mint-token") {
835
+ try {
836
+ return await runMintToken(args.slice(1), normalized);
837
+ } catch (err) {
838
+ const msg = err instanceof Error ? err.message : String(err);
839
+ console.error(`parachute auth mint-token: ${msg}`);
840
+ return 1;
841
+ }
842
+ }
843
+ if (sub === "pending-clients") {
844
+ try {
845
+ return runPendingClients(normalized);
846
+ } catch (err) {
847
+ const msg = err instanceof Error ? err.message : String(err);
848
+ console.error(`parachute auth pending-clients: ${msg}`);
849
+ return 1;
850
+ }
851
+ }
852
+ if (sub === "approve-client") {
853
+ try {
854
+ return runApproveClient(args.slice(1), normalized);
855
+ } catch (err) {
856
+ const msg = err instanceof Error ? err.message : String(err);
857
+ console.error(`parachute auth approve-client: ${msg}`);
858
+ return 1;
859
+ }
860
+ }
861
+ if (sub === "list-grants") {
862
+ try {
863
+ return runListGrants(args.slice(1), normalized);
864
+ } catch (err) {
865
+ const msg = err instanceof Error ? err.message : String(err);
866
+ console.error(`parachute auth list-grants: ${msg}`);
867
+ return 1;
868
+ }
869
+ }
870
+ if (sub === "revoke-grant") {
871
+ try {
872
+ return runRevokeGrant(args.slice(1), normalized);
873
+ } catch (err) {
874
+ const msg = err instanceof Error ? err.message : String(err);
875
+ console.error(`parachute auth revoke-grant: ${msg}`);
876
+ return 1;
877
+ }
878
+ }
879
+ }
880
+
881
+ if (!VAULT_FORWARDED_SUBCOMMANDS.has(sub)) {
53
882
  console.error(`parachute auth: unknown subcommand "${sub}"`);
54
883
  console.error("run `parachute auth --help` for usage");
55
884
  return 1;
@@ -67,3 +896,6 @@ export async function auth(
67
896
  return 1;
68
897
  }
69
898
  }
899
+
900
+ // Re-exported so `users.ts` consumers can preserve the named-export.
901
+ export { getUserByUsername };