@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.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
package/src/commands/auth.ts
CHANGED
|
@@ -1,14 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `parachute auth` — ecosystem-level identity commands.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
parachute auth
|
|
32
|
-
parachute auth 2fa
|
|
33
|
-
parachute auth 2fa
|
|
34
|
-
parachute auth 2fa
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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 };
|