@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
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { registerClient } from "../clients.ts";
|
|
6
|
+
import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
|
|
7
|
+
import { findGrant, recordGrant } from "../grants.ts";
|
|
8
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
9
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
10
|
+
import {
|
|
11
|
+
OPERATOR_TOKEN_AUDIENCE,
|
|
12
|
+
OPERATOR_TOKEN_CLIENT_ID,
|
|
13
|
+
OPERATOR_TOKEN_SCOPES,
|
|
14
|
+
readOperatorTokenFile,
|
|
15
|
+
writeOperatorTokenFile,
|
|
16
|
+
} from "../operator-token.ts";
|
|
17
|
+
import { createUser, listUsers, verifyPassword } from "../users.ts";
|
|
3
18
|
|
|
4
19
|
function makeRunner(result: number | (() => Promise<number>) = 0): {
|
|
5
20
|
runner: Runner;
|
|
@@ -15,21 +30,41 @@ function makeRunner(result: number | (() => Promise<number>) = 0): {
|
|
|
15
30
|
return { runner, calls };
|
|
16
31
|
}
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
33
|
+
function makeTmp(): { dir: string; dbPath: string; cleanup: () => void } {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-auth-"));
|
|
35
|
+
return {
|
|
36
|
+
dir,
|
|
37
|
+
dbPath: hubDbPath(dir),
|
|
38
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
25
41
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
42
|
+
/** Capture console.log + console.error output for the duration of `fn`. */
|
|
43
|
+
async function captureOutput(fn: () => Promise<number> | number): Promise<{
|
|
44
|
+
code: number;
|
|
45
|
+
stdout: string;
|
|
46
|
+
stderr: string;
|
|
47
|
+
}> {
|
|
48
|
+
const origLog = console.log;
|
|
49
|
+
const origErr = console.error;
|
|
50
|
+
let stdout = "";
|
|
51
|
+
let stderr = "";
|
|
52
|
+
console.log = (...a: unknown[]) => {
|
|
53
|
+
stdout += `${a.map(String).join(" ")}\n`;
|
|
54
|
+
};
|
|
55
|
+
console.error = (...a: unknown[]) => {
|
|
56
|
+
stderr += `${a.map(String).join(" ")}\n`;
|
|
57
|
+
};
|
|
58
|
+
try {
|
|
59
|
+
const code = await fn();
|
|
60
|
+
return { code, stdout, stderr };
|
|
61
|
+
} finally {
|
|
62
|
+
console.log = origLog;
|
|
63
|
+
console.error = origErr;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
32
66
|
|
|
67
|
+
describe("parachute auth", () => {
|
|
33
68
|
test("2fa enroll forwards to parachute-vault 2fa enroll", async () => {
|
|
34
69
|
const { runner, calls } = makeRunner(0);
|
|
35
70
|
const code = await auth(["2fa", "enroll"], runner);
|
|
@@ -50,16 +85,34 @@ describe("parachute auth", () => {
|
|
|
50
85
|
expect(code).toBe(3);
|
|
51
86
|
});
|
|
52
87
|
|
|
53
|
-
test("ENOENT surfaces install hint and exit 127", async () => {
|
|
88
|
+
test("ENOENT on a vault-forwarded subcommand surfaces install hint and exit 127", async () => {
|
|
54
89
|
const runner: Runner = {
|
|
55
90
|
async run() {
|
|
56
91
|
throw new Error("ENOENT: spawn parachute-vault");
|
|
57
92
|
},
|
|
58
93
|
};
|
|
59
|
-
const code = await auth(["
|
|
94
|
+
const code = await auth(["2fa", "status"], runner);
|
|
60
95
|
expect(code).toBe(127);
|
|
61
96
|
});
|
|
62
97
|
|
|
98
|
+
test("set-password no longer forwards to vault", async () => {
|
|
99
|
+
const tmp = makeTmp();
|
|
100
|
+
try {
|
|
101
|
+
const { runner, calls } = makeRunner(0);
|
|
102
|
+
const code = await auth(["set-password", "--password", "pw"], {
|
|
103
|
+
runner,
|
|
104
|
+
dbPath: tmp.dbPath,
|
|
105
|
+
configDir: tmp.dir,
|
|
106
|
+
isInteractive: () => false,
|
|
107
|
+
});
|
|
108
|
+
expect(code).toBe(0);
|
|
109
|
+
// Did NOT spawn parachute-vault.
|
|
110
|
+
expect(calls).toEqual([]);
|
|
111
|
+
} finally {
|
|
112
|
+
tmp.cleanup();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
63
116
|
test("bogus subcommand exits 1 without spawning vault", async () => {
|
|
64
117
|
const { runner, calls } = makeRunner(0);
|
|
65
118
|
const code = await auth(["whoami"], runner);
|
|
@@ -88,14 +141,1007 @@ describe("authHelp", () => {
|
|
|
88
141
|
|
|
89
142
|
test("lists every blessed subcommand", () => {
|
|
90
143
|
expect(h).toContain("parachute auth set-password");
|
|
91
|
-
expect(h).toContain("
|
|
144
|
+
expect(h).toContain("parachute auth list-users");
|
|
92
145
|
expect(h).toContain("parachute auth 2fa status");
|
|
93
146
|
expect(h).toContain("parachute auth 2fa enroll");
|
|
94
147
|
expect(h).toContain("parachute auth 2fa disable");
|
|
95
148
|
expect(h).toContain("parachute auth 2fa backup-codes");
|
|
149
|
+
expect(h).toContain("parachute auth rotate-key");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("set-password help mentions the new flags + hub-local home", () => {
|
|
153
|
+
expect(h).toContain("--username");
|
|
154
|
+
expect(h).toContain("--allow-multi");
|
|
155
|
+
expect(h).toContain("hub.db");
|
|
96
156
|
});
|
|
97
157
|
|
|
98
158
|
test("mentions the vault-install hint", () => {
|
|
99
159
|
expect(h).toContain("parachute install vault");
|
|
100
160
|
});
|
|
161
|
+
|
|
162
|
+
test("rotate-key explains the 24h JWKS retention", () => {
|
|
163
|
+
expect(h).toContain("jwks.json");
|
|
164
|
+
// "24" + "hours" may be split by line wrap; check both pieces.
|
|
165
|
+
expect(h).toContain("24");
|
|
166
|
+
expect(h).toContain("hours");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("parachute auth rotate-key", () => {
|
|
171
|
+
test("invokes the rotate hook and exits 0; does not spawn vault", async () => {
|
|
172
|
+
const { runner, calls } = makeRunner(0);
|
|
173
|
+
let hookCalls = 0;
|
|
174
|
+
const code = await auth(["rotate-key"], {
|
|
175
|
+
runner,
|
|
176
|
+
rotateKey: () => {
|
|
177
|
+
hookCalls++;
|
|
178
|
+
return { kid: "test-kid-abc", createdAt: "2026-04-26T00:00:00.000Z" };
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
expect(code).toBe(0);
|
|
182
|
+
expect(hookCalls).toBe(1);
|
|
183
|
+
expect(calls).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("propagates rotate errors as exit 1", async () => {
|
|
187
|
+
const code = await auth(["rotate-key"], {
|
|
188
|
+
rotateKey: () => {
|
|
189
|
+
throw new Error("disk full");
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
expect(code).toBe(1);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("parachute auth set-password", () => {
|
|
197
|
+
test("creates the first user with --password (non-interactive)", async () => {
|
|
198
|
+
const tmp = makeTmp();
|
|
199
|
+
try {
|
|
200
|
+
const deps: AuthDeps = {
|
|
201
|
+
dbPath: tmp.dbPath,
|
|
202
|
+
configDir: tmp.dir,
|
|
203
|
+
isInteractive: () => false,
|
|
204
|
+
};
|
|
205
|
+
const { code, stdout } = await captureOutput(() =>
|
|
206
|
+
auth(["set-password", "--password", "hunter2"], deps),
|
|
207
|
+
);
|
|
208
|
+
expect(code).toBe(0);
|
|
209
|
+
expect(stdout).toContain("Created hub user");
|
|
210
|
+
expect(stdout).toContain("owner");
|
|
211
|
+
const db = openHubDb(tmp.dbPath);
|
|
212
|
+
try {
|
|
213
|
+
const users = listUsers(db);
|
|
214
|
+
expect(users).toHaveLength(1);
|
|
215
|
+
expect(users[0]?.username).toBe("owner");
|
|
216
|
+
expect(await verifyPassword(users[0]!, "hunter2")).toBe(true);
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
tmp.cleanup();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("creates with a custom --username", async () => {
|
|
226
|
+
const tmp = makeTmp();
|
|
227
|
+
try {
|
|
228
|
+
const { code } = await captureOutput(() =>
|
|
229
|
+
auth(["set-password", "--username", "aaron", "--password", "pw"], {
|
|
230
|
+
dbPath: tmp.dbPath,
|
|
231
|
+
configDir: tmp.dir,
|
|
232
|
+
isInteractive: () => false,
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
expect(code).toBe(0);
|
|
236
|
+
const db = openHubDb(tmp.dbPath);
|
|
237
|
+
try {
|
|
238
|
+
expect(listUsers(db).map((u) => u.username)).toEqual(["aaron"]);
|
|
239
|
+
} finally {
|
|
240
|
+
db.close();
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
tmp.cleanup();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("updates the existing user's password (single-user mode)", async () => {
|
|
248
|
+
const tmp = makeTmp();
|
|
249
|
+
try {
|
|
250
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
|
|
251
|
+
// First-run create.
|
|
252
|
+
await captureOutput(() => auth(["set-password", "--password", "old"], deps));
|
|
253
|
+
// Update.
|
|
254
|
+
const { code, stdout } = await captureOutput(() =>
|
|
255
|
+
auth(["set-password", "--password", "new"], deps),
|
|
256
|
+
);
|
|
257
|
+
expect(code).toBe(0);
|
|
258
|
+
expect(stdout).toContain("Updated password");
|
|
259
|
+
const db = openHubDb(tmp.dbPath);
|
|
260
|
+
try {
|
|
261
|
+
const u = listUsers(db)[0]!;
|
|
262
|
+
expect(await verifyPassword(u, "new")).toBe(true);
|
|
263
|
+
expect(await verifyPassword(u, "old")).toBe(false);
|
|
264
|
+
} finally {
|
|
265
|
+
db.close();
|
|
266
|
+
}
|
|
267
|
+
} finally {
|
|
268
|
+
tmp.cleanup();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("rejects --username mismatch without --allow-multi", async () => {
|
|
273
|
+
const tmp = makeTmp();
|
|
274
|
+
try {
|
|
275
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
|
|
276
|
+
await captureOutput(() => auth(["set-password", "--password", "p"], deps));
|
|
277
|
+
const { code, stderr } = await captureOutput(() =>
|
|
278
|
+
auth(["set-password", "--username", "second", "--password", "p"], deps),
|
|
279
|
+
);
|
|
280
|
+
expect(code).toBe(1);
|
|
281
|
+
expect(stderr).toContain("already exists");
|
|
282
|
+
} finally {
|
|
283
|
+
tmp.cleanup();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("creates a second user with --allow-multi", async () => {
|
|
288
|
+
const tmp = makeTmp();
|
|
289
|
+
try {
|
|
290
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
|
|
291
|
+
await captureOutput(() => auth(["set-password", "--password", "p"], deps));
|
|
292
|
+
const { code } = await captureOutput(() =>
|
|
293
|
+
auth(["set-password", "--username", "second", "--password", "p", "--allow-multi"], deps),
|
|
294
|
+
);
|
|
295
|
+
expect(code).toBe(0);
|
|
296
|
+
const db = openHubDb(tmp.dbPath);
|
|
297
|
+
try {
|
|
298
|
+
expect(
|
|
299
|
+
listUsers(db)
|
|
300
|
+
.map((u) => u.username)
|
|
301
|
+
.sort(),
|
|
302
|
+
).toEqual(["owner", "second"]);
|
|
303
|
+
} finally {
|
|
304
|
+
db.close();
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
tmp.cleanup();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("non-interactive without --password is an error", async () => {
|
|
312
|
+
const tmp = makeTmp();
|
|
313
|
+
try {
|
|
314
|
+
const { code, stderr } = await captureOutput(() =>
|
|
315
|
+
auth(["set-password"], {
|
|
316
|
+
dbPath: tmp.dbPath,
|
|
317
|
+
configDir: tmp.dir,
|
|
318
|
+
isInteractive: () => false,
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
expect(code).toBe(1);
|
|
322
|
+
expect(stderr).toContain("--password is required");
|
|
323
|
+
} finally {
|
|
324
|
+
tmp.cleanup();
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("interactive: prompts twice and creates the user when they match", async () => {
|
|
329
|
+
const tmp = makeTmp();
|
|
330
|
+
try {
|
|
331
|
+
const prompts: string[] = [];
|
|
332
|
+
const deps: AuthDeps = {
|
|
333
|
+
dbPath: tmp.dbPath,
|
|
334
|
+
configDir: tmp.dir,
|
|
335
|
+
isInteractive: () => true,
|
|
336
|
+
readPassword: async (p) => {
|
|
337
|
+
prompts.push(p);
|
|
338
|
+
return "matched";
|
|
339
|
+
},
|
|
340
|
+
readLine: async () => "y",
|
|
341
|
+
};
|
|
342
|
+
const { code } = await captureOutput(() => auth(["set-password"], deps));
|
|
343
|
+
expect(code).toBe(0);
|
|
344
|
+
expect(prompts.length).toBe(2);
|
|
345
|
+
const db = openHubDb(tmp.dbPath);
|
|
346
|
+
try {
|
|
347
|
+
const u = listUsers(db)[0]!;
|
|
348
|
+
expect(await verifyPassword(u, "matched")).toBe(true);
|
|
349
|
+
} finally {
|
|
350
|
+
db.close();
|
|
351
|
+
}
|
|
352
|
+
} finally {
|
|
353
|
+
tmp.cleanup();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("interactive: mismatched confirmation aborts with exit 1", async () => {
|
|
358
|
+
const tmp = makeTmp();
|
|
359
|
+
try {
|
|
360
|
+
const answers = ["one", "two"];
|
|
361
|
+
const deps: AuthDeps = {
|
|
362
|
+
dbPath: tmp.dbPath,
|
|
363
|
+
configDir: tmp.dir,
|
|
364
|
+
isInteractive: () => true,
|
|
365
|
+
readPassword: async () => answers.shift() ?? "",
|
|
366
|
+
readLine: async () => "y",
|
|
367
|
+
};
|
|
368
|
+
const { code, stderr } = await captureOutput(() => auth(["set-password"], deps));
|
|
369
|
+
expect(code).toBe(1);
|
|
370
|
+
expect(stderr).toContain("did not match");
|
|
371
|
+
} finally {
|
|
372
|
+
tmp.cleanup();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("interactive: empty password aborts with exit 1", async () => {
|
|
377
|
+
const tmp = makeTmp();
|
|
378
|
+
try {
|
|
379
|
+
const deps: AuthDeps = {
|
|
380
|
+
dbPath: tmp.dbPath,
|
|
381
|
+
configDir: tmp.dir,
|
|
382
|
+
isInteractive: () => true,
|
|
383
|
+
readPassword: async () => "",
|
|
384
|
+
readLine: async () => "y",
|
|
385
|
+
};
|
|
386
|
+
const { code, stderr } = await captureOutput(() => auth(["set-password"], deps));
|
|
387
|
+
expect(code).toBe(1);
|
|
388
|
+
expect(stderr).toContain("empty");
|
|
389
|
+
} finally {
|
|
390
|
+
tmp.cleanup();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("first-run interactive: declining the default-username confirmation aborts", async () => {
|
|
395
|
+
const tmp = makeTmp();
|
|
396
|
+
try {
|
|
397
|
+
const deps: AuthDeps = {
|
|
398
|
+
dbPath: tmp.dbPath,
|
|
399
|
+
configDir: tmp.dir,
|
|
400
|
+
isInteractive: () => true,
|
|
401
|
+
readPassword: async () => "pw",
|
|
402
|
+
readLine: async () => "n",
|
|
403
|
+
};
|
|
404
|
+
const { code, stderr } = await captureOutput(() => auth(["set-password"], deps));
|
|
405
|
+
expect(code).toBe(1);
|
|
406
|
+
expect(stderr).toContain("aborted");
|
|
407
|
+
} finally {
|
|
408
|
+
tmp.cleanup();
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("unknown flag exits 1", async () => {
|
|
413
|
+
const tmp = makeTmp();
|
|
414
|
+
try {
|
|
415
|
+
const { code, stderr } = await captureOutput(() =>
|
|
416
|
+
auth(["set-password", "--lol"], {
|
|
417
|
+
dbPath: tmp.dbPath,
|
|
418
|
+
configDir: tmp.dir,
|
|
419
|
+
isInteractive: () => false,
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
expect(code).toBe(1);
|
|
423
|
+
expect(stderr).toContain("unknown flag");
|
|
424
|
+
} finally {
|
|
425
|
+
tmp.cleanup();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe("parachute auth list-users", () => {
|
|
431
|
+
test("empty state prints the seeding hint", async () => {
|
|
432
|
+
const tmp = makeTmp();
|
|
433
|
+
try {
|
|
434
|
+
const { code, stdout } = await captureOutput(() =>
|
|
435
|
+
auth(["list-users"], { dbPath: tmp.dbPath }),
|
|
436
|
+
);
|
|
437
|
+
expect(code).toBe(0);
|
|
438
|
+
expect(stdout).toContain("no hub users yet");
|
|
439
|
+
} finally {
|
|
440
|
+
tmp.cleanup();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("lists usernames after a set-password", async () => {
|
|
445
|
+
const tmp = makeTmp();
|
|
446
|
+
try {
|
|
447
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath, configDir: tmp.dir, isInteractive: () => false };
|
|
448
|
+
await captureOutput(() =>
|
|
449
|
+
auth(["set-password", "--username", "alice", "--password", "p"], deps),
|
|
450
|
+
);
|
|
451
|
+
const { code, stdout } = await captureOutput(() => auth(["list-users"], deps));
|
|
452
|
+
expect(code).toBe(0);
|
|
453
|
+
expect(stdout).toContain("USERNAME");
|
|
454
|
+
expect(stdout).toContain("alice");
|
|
455
|
+
} finally {
|
|
456
|
+
tmp.cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("set-password operator-token side-effect", () => {
|
|
462
|
+
// First-run set-password must seed ~/.parachute/operator.token. Without
|
|
463
|
+
// this, on-box CLI callers have nothing to present as a bearer when the
|
|
464
|
+
// hub starts requiring auth on every request (no loopback bypass).
|
|
465
|
+
test("creates operator.token on first-run, signed against active key, audience=operator", async () => {
|
|
466
|
+
const tmp = makeTmp();
|
|
467
|
+
try {
|
|
468
|
+
const deps: AuthDeps = {
|
|
469
|
+
dbPath: tmp.dbPath,
|
|
470
|
+
configDir: tmp.dir,
|
|
471
|
+
isInteractive: () => false,
|
|
472
|
+
};
|
|
473
|
+
const { code, stdout } = await captureOutput(() =>
|
|
474
|
+
auth(["set-password", "--password", "pw"], deps),
|
|
475
|
+
);
|
|
476
|
+
expect(code).toBe(0);
|
|
477
|
+
expect(stdout).toContain("operator token");
|
|
478
|
+
const tokenOnDisk = await readOperatorTokenFile(tmp.dir);
|
|
479
|
+
expect(tokenOnDisk).not.toBeNull();
|
|
480
|
+
const db = openHubDb(tmp.dbPath);
|
|
481
|
+
try {
|
|
482
|
+
const validated = await validateAccessToken(db, tokenOnDisk ?? "");
|
|
483
|
+
expect(validated.payload.aud).toBe(OPERATOR_TOKEN_AUDIENCE);
|
|
484
|
+
expect(validated.payload.scope).toBe(OPERATOR_TOKEN_SCOPES.join(" "));
|
|
485
|
+
const users = listUsers(db);
|
|
486
|
+
expect(validated.payload.sub).toBe(users[0]?.id);
|
|
487
|
+
} finally {
|
|
488
|
+
db.close();
|
|
489
|
+
}
|
|
490
|
+
} finally {
|
|
491
|
+
tmp.cleanup();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Password reset rotates the file too — old token stays valid until its
|
|
496
|
+
// 1y TTL expires (the hub doesn't track operator-token jtis), but the
|
|
497
|
+
// file always carries the freshest one.
|
|
498
|
+
test("password update overwrites operator.token with a fresh JWT", async () => {
|
|
499
|
+
const tmp = makeTmp();
|
|
500
|
+
try {
|
|
501
|
+
const deps: AuthDeps = {
|
|
502
|
+
dbPath: tmp.dbPath,
|
|
503
|
+
configDir: tmp.dir,
|
|
504
|
+
isInteractive: () => false,
|
|
505
|
+
};
|
|
506
|
+
await captureOutput(() => auth(["set-password", "--password", "old"], deps));
|
|
507
|
+
const first = await readOperatorTokenFile(tmp.dir);
|
|
508
|
+
// Sleep a beat to make sure the new JWT has a different iat — JWT
|
|
509
|
+
// claims are second-precision.
|
|
510
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
511
|
+
await captureOutput(() => auth(["set-password", "--password", "new"], deps));
|
|
512
|
+
const second = await readOperatorTokenFile(tmp.dir);
|
|
513
|
+
expect(second).not.toBeNull();
|
|
514
|
+
expect(second).not.toBe(first);
|
|
515
|
+
} finally {
|
|
516
|
+
tmp.cleanup();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("parachute auth rotate-operator", () => {
|
|
522
|
+
test("mints a fresh token, overwrites the file, exits 0", async () => {
|
|
523
|
+
const tmp = makeTmp();
|
|
524
|
+
try {
|
|
525
|
+
const deps: AuthDeps = {
|
|
526
|
+
dbPath: tmp.dbPath,
|
|
527
|
+
configDir: tmp.dir,
|
|
528
|
+
isInteractive: () => false,
|
|
529
|
+
};
|
|
530
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
531
|
+
const before = await readOperatorTokenFile(tmp.dir);
|
|
532
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
533
|
+
const { code, stdout } = await captureOutput(() => auth(["rotate-operator"], deps));
|
|
534
|
+
expect(code).toBe(0);
|
|
535
|
+
expect(stdout).toContain("Rotated operator token");
|
|
536
|
+
const after = await readOperatorTokenFile(tmp.dir);
|
|
537
|
+
expect(after).not.toBeNull();
|
|
538
|
+
expect(after).not.toBe(before);
|
|
539
|
+
} finally {
|
|
540
|
+
tmp.cleanup();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("with no users yet, exits 1 with a hint to run set-password", async () => {
|
|
545
|
+
const tmp = makeTmp();
|
|
546
|
+
try {
|
|
547
|
+
const deps: AuthDeps = {
|
|
548
|
+
dbPath: tmp.dbPath,
|
|
549
|
+
configDir: tmp.dir,
|
|
550
|
+
isInteractive: () => false,
|
|
551
|
+
};
|
|
552
|
+
const { code, stderr } = await captureOutput(() => auth(["rotate-operator"], deps));
|
|
553
|
+
expect(code).toBe(1);
|
|
554
|
+
expect(stderr).toContain("set-password");
|
|
555
|
+
} finally {
|
|
556
|
+
tmp.cleanup();
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// closes #74 — the operator's surface for the DCR approval gate. The CLI
|
|
562
|
+
// is the only approval path at launch (no admin UI yet); these tests pin
|
|
563
|
+
// the round-trip so an operator can promote a pending registration.
|
|
564
|
+
describe("parachute auth pending-clients / approve-client", () => {
|
|
565
|
+
test("pending-clients on an empty db says '(no pending OAuth clients)'", async () => {
|
|
566
|
+
const tmp = makeTmp();
|
|
567
|
+
try {
|
|
568
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
569
|
+
const { code, stdout } = await captureOutput(() => auth(["pending-clients"], deps));
|
|
570
|
+
expect(code).toBe(0);
|
|
571
|
+
expect(stdout).toContain("no pending OAuth clients");
|
|
572
|
+
} finally {
|
|
573
|
+
tmp.cleanup();
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("pending-clients lists pending rows; approve-client promotes them", async () => {
|
|
578
|
+
const tmp = makeTmp();
|
|
579
|
+
try {
|
|
580
|
+
const { registerClient } = await import("../clients.ts");
|
|
581
|
+
const db = openHubDb(tmp.dbPath);
|
|
582
|
+
let pendingId: string;
|
|
583
|
+
try {
|
|
584
|
+
pendingId = registerClient(db, {
|
|
585
|
+
redirectUris: ["https://app.example/cb"],
|
|
586
|
+
status: "pending",
|
|
587
|
+
clientName: "MyApp",
|
|
588
|
+
}).client.clientId;
|
|
589
|
+
registerClient(db, {
|
|
590
|
+
redirectUris: ["https://approved.example/cb"],
|
|
591
|
+
status: "approved",
|
|
592
|
+
clientName: "Already",
|
|
593
|
+
});
|
|
594
|
+
} finally {
|
|
595
|
+
db.close();
|
|
596
|
+
}
|
|
597
|
+
const deps: AuthDeps = { dbPath: tmp.dbPath };
|
|
598
|
+
|
|
599
|
+
// pending-clients shows only the pending row.
|
|
600
|
+
const list = await captureOutput(() => auth(["pending-clients"], deps));
|
|
601
|
+
expect(list.code).toBe(0);
|
|
602
|
+
expect(list.stdout).toContain(pendingId);
|
|
603
|
+
expect(list.stdout).toContain("MyApp");
|
|
604
|
+
expect(list.stdout).not.toContain("approved.example");
|
|
605
|
+
|
|
606
|
+
// approve-client without an arg is a usage error.
|
|
607
|
+
const noArg = await captureOutput(() => auth(["approve-client"], deps));
|
|
608
|
+
expect(noArg.code).toBe(1);
|
|
609
|
+
expect(noArg.stderr).toContain("missing client_id");
|
|
610
|
+
|
|
611
|
+
// approve-client <unknown> is a 1.
|
|
612
|
+
const unknown = await captureOutput(() => auth(["approve-client", "no-such"], deps));
|
|
613
|
+
expect(unknown.code).toBe(1);
|
|
614
|
+
expect(unknown.stderr).toContain("no OAuth client");
|
|
615
|
+
|
|
616
|
+
// approve-client <pending> succeeds and the row drops off pending-clients.
|
|
617
|
+
const ok = await captureOutput(() => auth(["approve-client", pendingId], deps));
|
|
618
|
+
expect(ok.code).toBe(0);
|
|
619
|
+
expect(ok.stdout).toContain("Approved");
|
|
620
|
+
const after = await captureOutput(() => auth(["pending-clients"], deps));
|
|
621
|
+
expect(after.stdout).toContain("no pending OAuth clients");
|
|
622
|
+
} finally {
|
|
623
|
+
tmp.cleanup();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// closes #75 — operator-facing controls for the OAuth consent skip-list.
|
|
629
|
+
describe("parachute auth list-grants / revoke-grant", () => {
|
|
630
|
+
test("list-grants shows the seeding hint when no users exist", async () => {
|
|
631
|
+
const tmp = makeTmp();
|
|
632
|
+
try {
|
|
633
|
+
const { code, stderr } = await captureOutput(() =>
|
|
634
|
+
auth(["list-grants"], { dbPath: tmp.dbPath }),
|
|
635
|
+
);
|
|
636
|
+
expect(code).toBe(1);
|
|
637
|
+
expect(stderr).toContain("no hub users yet");
|
|
638
|
+
} finally {
|
|
639
|
+
tmp.cleanup();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("list-grants shows '(no OAuth grants)' when the user has none", async () => {
|
|
644
|
+
const tmp = makeTmp();
|
|
645
|
+
try {
|
|
646
|
+
const db = openHubDb(tmp.dbPath);
|
|
647
|
+
try {
|
|
648
|
+
await createUser(db, "owner", "pw");
|
|
649
|
+
} finally {
|
|
650
|
+
db.close();
|
|
651
|
+
}
|
|
652
|
+
const { code, stdout } = await captureOutput(() =>
|
|
653
|
+
auth(["list-grants"], { dbPath: tmp.dbPath }),
|
|
654
|
+
);
|
|
655
|
+
expect(code).toBe(0);
|
|
656
|
+
expect(stdout).toContain("no OAuth grants on record");
|
|
657
|
+
expect(stdout).toContain("owner");
|
|
658
|
+
} finally {
|
|
659
|
+
tmp.cleanup();
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("list-grants prints rows with client_id + client_name + scopes", async () => {
|
|
664
|
+
const tmp = makeTmp();
|
|
665
|
+
try {
|
|
666
|
+
const db = openHubDb(tmp.dbPath);
|
|
667
|
+
let userId: string;
|
|
668
|
+
let clientId: string;
|
|
669
|
+
try {
|
|
670
|
+
const user = await createUser(db, "owner", "pw");
|
|
671
|
+
userId = user.id;
|
|
672
|
+
const reg = registerClient(db, {
|
|
673
|
+
redirectUris: ["https://app.example/cb"],
|
|
674
|
+
clientName: "MyApp",
|
|
675
|
+
});
|
|
676
|
+
clientId = reg.client.clientId;
|
|
677
|
+
recordGrant(db, userId, clientId, ["vault:default:read", "scribe:transcribe"]);
|
|
678
|
+
} finally {
|
|
679
|
+
db.close();
|
|
680
|
+
}
|
|
681
|
+
const { code, stdout } = await captureOutput(() =>
|
|
682
|
+
auth(["list-grants"], { dbPath: tmp.dbPath }),
|
|
683
|
+
);
|
|
684
|
+
expect(code).toBe(0);
|
|
685
|
+
expect(stdout).toContain(clientId);
|
|
686
|
+
expect(stdout).toContain("MyApp");
|
|
687
|
+
expect(stdout).toContain("vault:default:read");
|
|
688
|
+
expect(stdout).toContain("scribe:transcribe");
|
|
689
|
+
} finally {
|
|
690
|
+
tmp.cleanup();
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("revoke-grant without args prints usage", async () => {
|
|
695
|
+
const tmp = makeTmp();
|
|
696
|
+
try {
|
|
697
|
+
const { code, stderr } = await captureOutput(() =>
|
|
698
|
+
auth(["revoke-grant"], { dbPath: tmp.dbPath }),
|
|
699
|
+
);
|
|
700
|
+
expect(code).toBe(1);
|
|
701
|
+
expect(stderr).toContain("missing client_id");
|
|
702
|
+
} finally {
|
|
703
|
+
tmp.cleanup();
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("revoke-grant for an unknown client errors", async () => {
|
|
708
|
+
const tmp = makeTmp();
|
|
709
|
+
try {
|
|
710
|
+
const db = openHubDb(tmp.dbPath);
|
|
711
|
+
try {
|
|
712
|
+
await createUser(db, "owner", "pw");
|
|
713
|
+
} finally {
|
|
714
|
+
db.close();
|
|
715
|
+
}
|
|
716
|
+
const { code, stderr } = await captureOutput(() =>
|
|
717
|
+
auth(["revoke-grant", "no-such"], { dbPath: tmp.dbPath }),
|
|
718
|
+
);
|
|
719
|
+
expect(code).toBe(1);
|
|
720
|
+
expect(stderr).toContain("no grant on record");
|
|
721
|
+
} finally {
|
|
722
|
+
tmp.cleanup();
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("revoke-grant deletes the row and surfaces a friendly message", async () => {
|
|
727
|
+
const tmp = makeTmp();
|
|
728
|
+
try {
|
|
729
|
+
const db = openHubDb(tmp.dbPath);
|
|
730
|
+
let userId: string;
|
|
731
|
+
let clientId: string;
|
|
732
|
+
try {
|
|
733
|
+
const user = await createUser(db, "owner", "pw");
|
|
734
|
+
userId = user.id;
|
|
735
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
736
|
+
clientId = reg.client.clientId;
|
|
737
|
+
recordGrant(db, userId, clientId, ["vault:default:read"]);
|
|
738
|
+
expect(findGrant(db, userId, clientId)).not.toBeNull();
|
|
739
|
+
} finally {
|
|
740
|
+
db.close();
|
|
741
|
+
}
|
|
742
|
+
const { code, stdout } = await captureOutput(() =>
|
|
743
|
+
auth(["revoke-grant", clientId], { dbPath: tmp.dbPath }),
|
|
744
|
+
);
|
|
745
|
+
expect(code).toBe(0);
|
|
746
|
+
expect(stdout).toContain("Revoked OAuth grant");
|
|
747
|
+
expect(stdout).toContain("re-prompt for consent");
|
|
748
|
+
|
|
749
|
+
// Row gone.
|
|
750
|
+
const verifyDb = openHubDb(tmp.dbPath);
|
|
751
|
+
try {
|
|
752
|
+
expect(findGrant(verifyDb, userId, clientId)).toBeNull();
|
|
753
|
+
} finally {
|
|
754
|
+
verifyDb.close();
|
|
755
|
+
}
|
|
756
|
+
} finally {
|
|
757
|
+
tmp.cleanup();
|
|
758
|
+
}
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("multi-user mode requires --username on revoke-grant", async () => {
|
|
762
|
+
const tmp = makeTmp();
|
|
763
|
+
try {
|
|
764
|
+
const db = openHubDb(tmp.dbPath);
|
|
765
|
+
let aliceId: string;
|
|
766
|
+
let clientId: string;
|
|
767
|
+
try {
|
|
768
|
+
const alice = await createUser(db, "alice", "pw");
|
|
769
|
+
aliceId = alice.id;
|
|
770
|
+
await createUser(db, "bob", "pw", { allowMulti: true });
|
|
771
|
+
const reg = registerClient(db, { redirectUris: ["https://app.example/cb"] });
|
|
772
|
+
clientId = reg.client.clientId;
|
|
773
|
+
recordGrant(db, aliceId, clientId, ["vault:default:read"]);
|
|
774
|
+
} finally {
|
|
775
|
+
db.close();
|
|
776
|
+
}
|
|
777
|
+
const ambig = await captureOutput(() =>
|
|
778
|
+
auth(["revoke-grant", clientId], { dbPath: tmp.dbPath }),
|
|
779
|
+
);
|
|
780
|
+
expect(ambig.code).toBe(1);
|
|
781
|
+
expect(ambig.stderr).toContain("multiple hub users exist");
|
|
782
|
+
|
|
783
|
+
const targeted = await captureOutput(() =>
|
|
784
|
+
auth(["revoke-grant", clientId, "--username", "alice"], { dbPath: tmp.dbPath }),
|
|
785
|
+
);
|
|
786
|
+
expect(targeted.code).toBe(0);
|
|
787
|
+
expect(targeted.stdout).toContain("alice");
|
|
788
|
+
|
|
789
|
+
// Bob never had this grant, so revoking his side is a 1.
|
|
790
|
+
const bobMiss = await captureOutput(() =>
|
|
791
|
+
auth(["revoke-grant", clientId, "--username", "bob"], { dbPath: tmp.dbPath }),
|
|
792
|
+
);
|
|
793
|
+
expect(bobMiss.code).toBe(1);
|
|
794
|
+
} finally {
|
|
795
|
+
tmp.cleanup();
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// closes #179 — scope-narrow JWT minting against operator identity, for
|
|
801
|
+
// agent-secret injection and other on-box callers that want a tight bearer.
|
|
802
|
+
describe("parachute auth mint-token", () => {
|
|
803
|
+
test("missing --scope is a usage error", async () => {
|
|
804
|
+
const tmp = makeTmp();
|
|
805
|
+
try {
|
|
806
|
+
const { code, stderr } = await captureOutput(() =>
|
|
807
|
+
auth(["mint-token"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
|
|
808
|
+
);
|
|
809
|
+
expect(code).toBe(1);
|
|
810
|
+
expect(stderr).toContain("--scope is required");
|
|
811
|
+
} finally {
|
|
812
|
+
tmp.cleanup();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test("no operator.token on disk is an actionable error", async () => {
|
|
817
|
+
const tmp = makeTmp();
|
|
818
|
+
try {
|
|
819
|
+
const { code, stderr } = await captureOutput(() =>
|
|
820
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], {
|
|
821
|
+
dbPath: tmp.dbPath,
|
|
822
|
+
configDir: tmp.dir,
|
|
823
|
+
}),
|
|
824
|
+
);
|
|
825
|
+
expect(code).toBe(1);
|
|
826
|
+
expect(stderr).toContain("operator.token");
|
|
827
|
+
expect(stderr).toContain("rotate-operator");
|
|
828
|
+
} finally {
|
|
829
|
+
tmp.cleanup();
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
test("scope-only mint emits a JWT signed by the active key, audience inferred", async () => {
|
|
834
|
+
const tmp = makeTmp();
|
|
835
|
+
try {
|
|
836
|
+
const deps: AuthDeps = {
|
|
837
|
+
dbPath: tmp.dbPath,
|
|
838
|
+
configDir: tmp.dir,
|
|
839
|
+
isInteractive: () => false,
|
|
840
|
+
};
|
|
841
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
842
|
+
const { code, stdout } = await captureOutput(() =>
|
|
843
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
844
|
+
);
|
|
845
|
+
expect(code).toBe(0);
|
|
846
|
+
const token = stdout.trim();
|
|
847
|
+
expect(token.split(".").length).toBe(3);
|
|
848
|
+
// Strict purity: stdout is exactly the token + trailing newline,
|
|
849
|
+
// nothing extra. Pipes (`| pbcopy`, `| jq`) depend on this.
|
|
850
|
+
expect(stdout).toBe(`${token}\n`);
|
|
851
|
+
const db = openHubDb(tmp.dbPath);
|
|
852
|
+
try {
|
|
853
|
+
const validated = await validateAccessToken(db, token);
|
|
854
|
+
expect(validated.payload.aud).toBe("scribe");
|
|
855
|
+
expect(validated.payload.scope).toBe("scribe:transcribe");
|
|
856
|
+
const users = listUsers(db);
|
|
857
|
+
expect(validated.payload.sub).toBe(users[0]?.id);
|
|
858
|
+
} finally {
|
|
859
|
+
db.close();
|
|
860
|
+
}
|
|
861
|
+
} finally {
|
|
862
|
+
tmp.cleanup();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("operator token without hub:admin scope is rejected (no token emitted)", async () => {
|
|
867
|
+
const tmp = makeTmp();
|
|
868
|
+
try {
|
|
869
|
+
const deps: AuthDeps = {
|
|
870
|
+
dbPath: tmp.dbPath,
|
|
871
|
+
configDir: tmp.dir,
|
|
872
|
+
isInteractive: () => false,
|
|
873
|
+
};
|
|
874
|
+
// Bootstrap: set-password to seed the user + signing key.
|
|
875
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
876
|
+
// Now overwrite operator.token with a valid-signature, valid-expiry
|
|
877
|
+
// JWT that lacks hub:admin — simulating someone stashing a narrow
|
|
878
|
+
// token at the operator path.
|
|
879
|
+
const db = openHubDb(tmp.dbPath);
|
|
880
|
+
let narrow: string;
|
|
881
|
+
try {
|
|
882
|
+
const owner = listUsers(db)[0]!;
|
|
883
|
+
const signed = await signAccessToken(db, {
|
|
884
|
+
sub: owner.id,
|
|
885
|
+
scopes: ["scribe:transcribe"],
|
|
886
|
+
audience: "scribe",
|
|
887
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
888
|
+
issuer: "http://127.0.0.1:1939",
|
|
889
|
+
ttlSeconds: 3600,
|
|
890
|
+
});
|
|
891
|
+
narrow = signed.token;
|
|
892
|
+
} finally {
|
|
893
|
+
db.close();
|
|
894
|
+
}
|
|
895
|
+
await writeOperatorTokenFile(narrow, tmp.dir);
|
|
896
|
+
|
|
897
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
898
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
899
|
+
);
|
|
900
|
+
expect(code).toBe(1);
|
|
901
|
+
expect(stderr).toContain("lacks hub:admin scope");
|
|
902
|
+
expect(stderr).toContain("rotate-operator");
|
|
903
|
+
// Purity: no token written to stdout.
|
|
904
|
+
expect(stdout).toBe("");
|
|
905
|
+
} finally {
|
|
906
|
+
tmp.cleanup();
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("named vault scope infers aud=vault.<name>", async () => {
|
|
911
|
+
const tmp = makeTmp();
|
|
912
|
+
try {
|
|
913
|
+
const deps: AuthDeps = {
|
|
914
|
+
dbPath: tmp.dbPath,
|
|
915
|
+
configDir: tmp.dir,
|
|
916
|
+
isInteractive: () => false,
|
|
917
|
+
};
|
|
918
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
919
|
+
const { code, stdout } = await captureOutput(() =>
|
|
920
|
+
auth(["mint-token", "--scope", "vault:work:read"], deps),
|
|
921
|
+
);
|
|
922
|
+
expect(code).toBe(0);
|
|
923
|
+
const token = stdout.trim();
|
|
924
|
+
const db = openHubDb(tmp.dbPath);
|
|
925
|
+
try {
|
|
926
|
+
const validated = await validateAccessToken(db, token);
|
|
927
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
928
|
+
} finally {
|
|
929
|
+
db.close();
|
|
930
|
+
}
|
|
931
|
+
} finally {
|
|
932
|
+
tmp.cleanup();
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test("--aud override beats inference", async () => {
|
|
937
|
+
const tmp = makeTmp();
|
|
938
|
+
try {
|
|
939
|
+
const deps: AuthDeps = {
|
|
940
|
+
dbPath: tmp.dbPath,
|
|
941
|
+
configDir: tmp.dir,
|
|
942
|
+
isInteractive: () => false,
|
|
943
|
+
};
|
|
944
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
945
|
+
const { code, stdout } = await captureOutput(() =>
|
|
946
|
+
auth(["mint-token", "--scope", "vault:work:read", "--aud", "custom-resource"], deps),
|
|
947
|
+
);
|
|
948
|
+
expect(code).toBe(0);
|
|
949
|
+
const token = stdout.trim();
|
|
950
|
+
const db = openHubDb(tmp.dbPath);
|
|
951
|
+
try {
|
|
952
|
+
const validated = await validateAccessToken(db, token);
|
|
953
|
+
expect(validated.payload.aud).toBe("custom-resource");
|
|
954
|
+
} finally {
|
|
955
|
+
db.close();
|
|
956
|
+
}
|
|
957
|
+
} finally {
|
|
958
|
+
tmp.cleanup();
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("--ttl honored; expiry math matches", async () => {
|
|
963
|
+
const tmp = makeTmp();
|
|
964
|
+
try {
|
|
965
|
+
const deps: AuthDeps = {
|
|
966
|
+
dbPath: tmp.dbPath,
|
|
967
|
+
configDir: tmp.dir,
|
|
968
|
+
isInteractive: () => false,
|
|
969
|
+
};
|
|
970
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
971
|
+
const { code, stdout } = await captureOutput(() =>
|
|
972
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "1h"], deps),
|
|
973
|
+
);
|
|
974
|
+
expect(code).toBe(0);
|
|
975
|
+
const token = stdout.trim();
|
|
976
|
+
const db = openHubDb(tmp.dbPath);
|
|
977
|
+
try {
|
|
978
|
+
const validated = await validateAccessToken(db, token);
|
|
979
|
+
const exp = validated.payload.exp;
|
|
980
|
+
const iat = validated.payload.iat;
|
|
981
|
+
if (typeof exp !== "number" || typeof iat !== "number") {
|
|
982
|
+
throw new Error("expected numeric exp+iat");
|
|
983
|
+
}
|
|
984
|
+
expect(exp - iat).toBe(3600);
|
|
985
|
+
} finally {
|
|
986
|
+
db.close();
|
|
987
|
+
}
|
|
988
|
+
} finally {
|
|
989
|
+
tmp.cleanup();
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test("--ttl=365d is accepted (boundary)", async () => {
|
|
994
|
+
const tmp = makeTmp();
|
|
995
|
+
try {
|
|
996
|
+
const deps: AuthDeps = {
|
|
997
|
+
dbPath: tmp.dbPath,
|
|
998
|
+
configDir: tmp.dir,
|
|
999
|
+
isInteractive: () => false,
|
|
1000
|
+
};
|
|
1001
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1002
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1003
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "365d"], deps),
|
|
1004
|
+
);
|
|
1005
|
+
expect(code).toBe(0);
|
|
1006
|
+
const token = stdout.trim();
|
|
1007
|
+
const db = openHubDb(tmp.dbPath);
|
|
1008
|
+
try {
|
|
1009
|
+
const validated = await validateAccessToken(db, token);
|
|
1010
|
+
const exp = validated.payload.exp;
|
|
1011
|
+
const iat = validated.payload.iat;
|
|
1012
|
+
if (typeof exp !== "number" || typeof iat !== "number") {
|
|
1013
|
+
throw new Error("expected numeric exp+iat");
|
|
1014
|
+
}
|
|
1015
|
+
expect(exp - iat).toBe(365 * 24 * 60 * 60);
|
|
1016
|
+
} finally {
|
|
1017
|
+
db.close();
|
|
1018
|
+
}
|
|
1019
|
+
} finally {
|
|
1020
|
+
tmp.cleanup();
|
|
1021
|
+
}
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test("--ttl=0s is rejected (must be > 0)", async () => {
|
|
1025
|
+
const tmp = makeTmp();
|
|
1026
|
+
try {
|
|
1027
|
+
const deps: AuthDeps = {
|
|
1028
|
+
dbPath: tmp.dbPath,
|
|
1029
|
+
configDir: tmp.dir,
|
|
1030
|
+
isInteractive: () => false,
|
|
1031
|
+
};
|
|
1032
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1033
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1034
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "0s"], deps),
|
|
1035
|
+
);
|
|
1036
|
+
expect(code).toBe(1);
|
|
1037
|
+
expect(stderr).toContain("must be > 0");
|
|
1038
|
+
} finally {
|
|
1039
|
+
tmp.cleanup();
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
test("--ttl > 365d errors", async () => {
|
|
1044
|
+
const tmp = makeTmp();
|
|
1045
|
+
try {
|
|
1046
|
+
const deps: AuthDeps = {
|
|
1047
|
+
dbPath: tmp.dbPath,
|
|
1048
|
+
configDir: tmp.dir,
|
|
1049
|
+
isInteractive: () => false,
|
|
1050
|
+
};
|
|
1051
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1052
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1053
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "400d"], deps),
|
|
1054
|
+
);
|
|
1055
|
+
expect(code).toBe(1);
|
|
1056
|
+
expect(stderr).toContain("365d cap");
|
|
1057
|
+
} finally {
|
|
1058
|
+
tmp.cleanup();
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("--ttl with invalid format errors", async () => {
|
|
1063
|
+
const tmp = makeTmp();
|
|
1064
|
+
try {
|
|
1065
|
+
const deps: AuthDeps = {
|
|
1066
|
+
dbPath: tmp.dbPath,
|
|
1067
|
+
configDir: tmp.dir,
|
|
1068
|
+
isInteractive: () => false,
|
|
1069
|
+
};
|
|
1070
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1071
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1072
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "1week"], deps),
|
|
1073
|
+
);
|
|
1074
|
+
expect(code).toBe(1);
|
|
1075
|
+
expect(stderr).toContain("invalid --ttl");
|
|
1076
|
+
} finally {
|
|
1077
|
+
tmp.cleanup();
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("multiple scopes (space-separated) carried verbatim into the JWT", async () => {
|
|
1082
|
+
const tmp = makeTmp();
|
|
1083
|
+
try {
|
|
1084
|
+
const deps: AuthDeps = {
|
|
1085
|
+
dbPath: tmp.dbPath,
|
|
1086
|
+
configDir: tmp.dir,
|
|
1087
|
+
isInteractive: () => false,
|
|
1088
|
+
};
|
|
1089
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1090
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1091
|
+
auth(["mint-token", "--scope", "vault:work:read scribe:transcribe"], deps),
|
|
1092
|
+
);
|
|
1093
|
+
expect(code).toBe(0);
|
|
1094
|
+
const token = stdout.trim();
|
|
1095
|
+
const db = openHubDb(tmp.dbPath);
|
|
1096
|
+
try {
|
|
1097
|
+
const validated = await validateAccessToken(db, token);
|
|
1098
|
+
expect(validated.payload.scope).toBe("vault:work:read scribe:transcribe");
|
|
1099
|
+
// Named vault scope wins for audience inference.
|
|
1100
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
1101
|
+
} finally {
|
|
1102
|
+
db.close();
|
|
1103
|
+
}
|
|
1104
|
+
} finally {
|
|
1105
|
+
tmp.cleanup();
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test("--sub override emits the JWT with that subject", async () => {
|
|
1110
|
+
const tmp = makeTmp();
|
|
1111
|
+
try {
|
|
1112
|
+
const deps: AuthDeps = {
|
|
1113
|
+
dbPath: tmp.dbPath,
|
|
1114
|
+
configDir: tmp.dir,
|
|
1115
|
+
isInteractive: () => false,
|
|
1116
|
+
};
|
|
1117
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1118
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1119
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--sub", "agent:scribe-runner"], deps),
|
|
1120
|
+
);
|
|
1121
|
+
expect(code).toBe(0);
|
|
1122
|
+
const token = stdout.trim();
|
|
1123
|
+
const db = openHubDb(tmp.dbPath);
|
|
1124
|
+
try {
|
|
1125
|
+
const validated = await validateAccessToken(db, token);
|
|
1126
|
+
expect(validated.payload.sub).toBe("agent:scribe-runner");
|
|
1127
|
+
} finally {
|
|
1128
|
+
db.close();
|
|
1129
|
+
}
|
|
1130
|
+
} finally {
|
|
1131
|
+
tmp.cleanup();
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
test("unknown flag errors", async () => {
|
|
1136
|
+
const tmp = makeTmp();
|
|
1137
|
+
try {
|
|
1138
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1139
|
+
auth(["mint-token", "--lol"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
|
|
1140
|
+
);
|
|
1141
|
+
expect(code).toBe(1);
|
|
1142
|
+
expect(stderr).toContain("unknown flag");
|
|
1143
|
+
} finally {
|
|
1144
|
+
tmp.cleanup();
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
101
1147
|
});
|