@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
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 +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -318,3 +318,387 @@ describe("POST /api/auth/revoke-token (closes hub#220)", () => {
|
|
|
318
318
|
}
|
|
319
319
|
});
|
|
320
320
|
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Capability attenuation — symmetric to mint-token (hub#452). A bearer that
|
|
324
|
+
// is NOT host:auth may revoke a jti iff every one of the jti's recorded scopes
|
|
325
|
+
// is one the bearer could have minted (`canGrant`): you may revoke what you
|
|
326
|
+
// could mint. This is the security-critical half of the auth arc.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
describe("POST /api/auth/revoke-token — capability attenuation (symmetric to hub#452)", () => {
|
|
329
|
+
/**
|
|
330
|
+
* Hand-mint a `vault:<vault>:admin` bearer the way the SPA / mcp-install
|
|
331
|
+
* path obtains one (scope `vault:<vault>:admin`, aud `vault.<vault>`,
|
|
332
|
+
* vaultScope `[vault]`). Mirrors `mintVaultAdminBearer` in
|
|
333
|
+
* api-mint-token.test.ts.
|
|
334
|
+
*/
|
|
335
|
+
async function mintVaultAdminBearer(
|
|
336
|
+
db: ReturnType<typeof openHubDb>,
|
|
337
|
+
userId: string,
|
|
338
|
+
vault: string,
|
|
339
|
+
): Promise<string> {
|
|
340
|
+
const signed = await signAccessToken(db, {
|
|
341
|
+
sub: userId,
|
|
342
|
+
scopes: [`vault:${vault}:admin`],
|
|
343
|
+
audience: `vault.${vault}`,
|
|
344
|
+
clientId: "parachute-hub",
|
|
345
|
+
issuer: ISSUER,
|
|
346
|
+
ttlSeconds: 3600,
|
|
347
|
+
vaultScope: [vault],
|
|
348
|
+
});
|
|
349
|
+
return signed.token;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
test("host:auth bearer revokes ANY jti (preserved) — incl. a host-scoped target", async () => {
|
|
353
|
+
const h = makeHarness();
|
|
354
|
+
try {
|
|
355
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
356
|
+
try {
|
|
357
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
358
|
+
// Seed a target whose own scopes a vault-admin could never mint.
|
|
359
|
+
const jti = await seedToken(db, userId, ["parachute:host:auth"]);
|
|
360
|
+
const resp = await handleApiRevokeToken(
|
|
361
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
362
|
+
{ db, issuer: ISSUER },
|
|
363
|
+
);
|
|
364
|
+
expect(resp.status).toBe(200);
|
|
365
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
366
|
+
} finally {
|
|
367
|
+
db.close();
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
h.cleanup();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("vault:work:admin revokes a vault:work:write jti → 200 (could have minted it)", async () => {
|
|
375
|
+
const h = makeHarness();
|
|
376
|
+
try {
|
|
377
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
378
|
+
try {
|
|
379
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
380
|
+
const jti = await seedToken(db, userId, ["vault:work:write"]);
|
|
381
|
+
const resp = await handleApiRevokeToken(
|
|
382
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
383
|
+
{ db, issuer: ISSUER },
|
|
384
|
+
);
|
|
385
|
+
expect(resp.status).toBe(200);
|
|
386
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
387
|
+
} finally {
|
|
388
|
+
db.close();
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
h.cleanup();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("vault:work:admin revokes a vault:work:admin jti → 200", async () => {
|
|
396
|
+
const h = makeHarness();
|
|
397
|
+
try {
|
|
398
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
399
|
+
try {
|
|
400
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
401
|
+
const jti = await seedToken(db, userId, ["vault:work:admin"]);
|
|
402
|
+
const resp = await handleApiRevokeToken(
|
|
403
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
404
|
+
{ db, issuer: ISSUER },
|
|
405
|
+
);
|
|
406
|
+
expect(resp.status).toBe(200);
|
|
407
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
408
|
+
} finally {
|
|
409
|
+
db.close();
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
h.cleanup();
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("CROSS-VAULT BLOCKED: vault:work:admin revokes vault:other:write jti → 403, NOT revoked", async () => {
|
|
417
|
+
const h = makeHarness();
|
|
418
|
+
try {
|
|
419
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
420
|
+
try {
|
|
421
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
422
|
+
const jti = await seedToken(db, userId, ["vault:other:write"]);
|
|
423
|
+
const resp = await handleApiRevokeToken(
|
|
424
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
425
|
+
{ db, issuer: ISSUER },
|
|
426
|
+
);
|
|
427
|
+
expect(resp.status).toBe(403);
|
|
428
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
429
|
+
// SECURITY: the cross-vault token must NOT have been revoked.
|
|
430
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
431
|
+
} finally {
|
|
432
|
+
db.close();
|
|
433
|
+
}
|
|
434
|
+
} finally {
|
|
435
|
+
h.cleanup();
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("HOST-ESCALATION BLOCKED: vault:work:admin revokes parachute:host:auth jti → 403, NOT revoked", async () => {
|
|
440
|
+
const h = makeHarness();
|
|
441
|
+
try {
|
|
442
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
443
|
+
try {
|
|
444
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
445
|
+
const jti = await seedToken(db, userId, ["parachute:host:auth"]);
|
|
446
|
+
const resp = await handleApiRevokeToken(
|
|
447
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
448
|
+
{ db, issuer: ISSUER },
|
|
449
|
+
);
|
|
450
|
+
expect(resp.status).toBe(403);
|
|
451
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
452
|
+
} finally {
|
|
453
|
+
db.close();
|
|
454
|
+
}
|
|
455
|
+
} finally {
|
|
456
|
+
h.cleanup();
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("NO LEAK: vault:work:admin revokes an UNKNOWN jti → 404 (same as host:auth, no leak)", async () => {
|
|
461
|
+
const h = makeHarness();
|
|
462
|
+
try {
|
|
463
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
464
|
+
try {
|
|
465
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
466
|
+
const resp = await handleApiRevokeToken(
|
|
467
|
+
jsonRequest({ jti: "no-such-jti-ever-minted" }, { authorization: `Bearer ${bearer}` }),
|
|
468
|
+
{ db, issuer: ISSUER },
|
|
469
|
+
);
|
|
470
|
+
// Identical to today's unknown-jti behavior for a host:auth bearer:
|
|
471
|
+
// the attenuated caller cannot distinguish "doesn't exist" here.
|
|
472
|
+
expect(resp.status).toBe(404);
|
|
473
|
+
expect(((await resp.json()) as { error: string }).error).toBe("not_found");
|
|
474
|
+
} finally {
|
|
475
|
+
db.close();
|
|
476
|
+
}
|
|
477
|
+
} finally {
|
|
478
|
+
h.cleanup();
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("ENTRY GATE: vault:work:read bearer (no admin, no host) → 403, NOT revoked", async () => {
|
|
483
|
+
const h = makeHarness();
|
|
484
|
+
try {
|
|
485
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
486
|
+
try {
|
|
487
|
+
const readOnly = await signAccessToken(db, {
|
|
488
|
+
sub: userId,
|
|
489
|
+
scopes: ["vault:work:read"],
|
|
490
|
+
audience: "vault.work",
|
|
491
|
+
clientId: "parachute-hub",
|
|
492
|
+
issuer: ISSUER,
|
|
493
|
+
ttlSeconds: 3600,
|
|
494
|
+
vaultScope: ["work"],
|
|
495
|
+
});
|
|
496
|
+
// Seed a target the read bearer could never mint anyway.
|
|
497
|
+
const jti = await seedToken(db, userId, ["vault:work:write"]);
|
|
498
|
+
const resp = await handleApiRevokeToken(
|
|
499
|
+
jsonRequest({ jti }, { authorization: `Bearer ${readOnly.token}` }),
|
|
500
|
+
{ db, issuer: ISSUER },
|
|
501
|
+
);
|
|
502
|
+
expect(resp.status).toBe(403);
|
|
503
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
504
|
+
// Entry-gated before any lookup — the target stays intact.
|
|
505
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
506
|
+
} finally {
|
|
507
|
+
db.close();
|
|
508
|
+
}
|
|
509
|
+
} finally {
|
|
510
|
+
h.cleanup();
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("MULTI-SCOPE: vault:work:admin revokes vault:work:read+write jti → 200 (all in authority)", async () => {
|
|
515
|
+
const h = makeHarness();
|
|
516
|
+
try {
|
|
517
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
518
|
+
try {
|
|
519
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
520
|
+
const jti = await seedToken(db, userId, ["vault:work:read", "vault:work:write"]);
|
|
521
|
+
const resp = await handleApiRevokeToken(
|
|
522
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
523
|
+
{ db, issuer: ISSUER },
|
|
524
|
+
);
|
|
525
|
+
expect(resp.status).toBe(200);
|
|
526
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
527
|
+
} finally {
|
|
528
|
+
db.close();
|
|
529
|
+
}
|
|
530
|
+
} finally {
|
|
531
|
+
h.cleanup();
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("MULTI-SCOPE BLOCKED: one out-of-authority scope blocks the whole revoke → 403, NOT revoked", async () => {
|
|
536
|
+
const h = makeHarness();
|
|
537
|
+
try {
|
|
538
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
539
|
+
try {
|
|
540
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
541
|
+
// In-authority scope + one cross-vault scope: must block entirely.
|
|
542
|
+
const jti = await seedToken(db, userId, ["vault:work:write", "vault:other:read"]);
|
|
543
|
+
const resp = await handleApiRevokeToken(
|
|
544
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
545
|
+
{ db, issuer: ISSUER },
|
|
546
|
+
);
|
|
547
|
+
expect(resp.status).toBe(403);
|
|
548
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
549
|
+
} finally {
|
|
550
|
+
db.close();
|
|
551
|
+
}
|
|
552
|
+
} finally {
|
|
553
|
+
h.cleanup();
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("host:admin bearer revokes a vault:<name>:admin jti → 200 (rule 2 symmetry)", async () => {
|
|
558
|
+
const h = makeHarness();
|
|
559
|
+
try {
|
|
560
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
561
|
+
try {
|
|
562
|
+
const hostAdmin = await signAccessToken(db, {
|
|
563
|
+
sub: userId,
|
|
564
|
+
scopes: ["parachute:host:admin"],
|
|
565
|
+
audience: "hub",
|
|
566
|
+
clientId: "parachute-hub",
|
|
567
|
+
issuer: ISSUER,
|
|
568
|
+
ttlSeconds: 3600,
|
|
569
|
+
});
|
|
570
|
+
const jti = await seedToken(db, userId, ["vault:work:admin"]);
|
|
571
|
+
const resp = await handleApiRevokeToken(
|
|
572
|
+
jsonRequest({ jti }, { authorization: `Bearer ${hostAdmin.token}` }),
|
|
573
|
+
{ db, issuer: ISSUER },
|
|
574
|
+
);
|
|
575
|
+
expect(resp.status).toBe(200);
|
|
576
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
577
|
+
} finally {
|
|
578
|
+
db.close();
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
h.cleanup();
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("HOST-ESCALATION BLOCKED: host:admin bearer revokes parachute:host:auth jti → 403, NOT revoked", async () => {
|
|
586
|
+
const h = makeHarness();
|
|
587
|
+
try {
|
|
588
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
589
|
+
try {
|
|
590
|
+
// host:admin is NOT host:auth, so it goes through the per-jti
|
|
591
|
+
// attenuation check. `parachute:host:auth` is non-requestable and not
|
|
592
|
+
// a vault-admin scope, so canGrant returns false for it → 403.
|
|
593
|
+
const hostAdmin = await signAccessToken(db, {
|
|
594
|
+
sub: userId,
|
|
595
|
+
scopes: ["parachute:host:admin"],
|
|
596
|
+
audience: "hub",
|
|
597
|
+
clientId: "parachute-hub",
|
|
598
|
+
issuer: ISSUER,
|
|
599
|
+
ttlSeconds: 3600,
|
|
600
|
+
});
|
|
601
|
+
const jti = await seedToken(db, userId, ["parachute:host:auth"]);
|
|
602
|
+
const resp = await handleApiRevokeToken(
|
|
603
|
+
jsonRequest({ jti }, { authorization: `Bearer ${hostAdmin.token}` }),
|
|
604
|
+
{ db, issuer: ISSUER },
|
|
605
|
+
);
|
|
606
|
+
expect(resp.status).toBe(403);
|
|
607
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
608
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
609
|
+
} finally {
|
|
610
|
+
db.close();
|
|
611
|
+
}
|
|
612
|
+
} finally {
|
|
613
|
+
h.cleanup();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("EMPTY-SCOPES GUARD: non-host:auth bearer cannot revoke a scopeless target → 403, NOT revoked", async () => {
|
|
618
|
+
const h = makeHarness();
|
|
619
|
+
try {
|
|
620
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
621
|
+
try {
|
|
622
|
+
const bearer = await mintVaultAdminBearer(db, userId, "work");
|
|
623
|
+
// Seed a registry row with ZERO recorded scopes directly — the CLI/SPA
|
|
624
|
+
// never mint these, but a vacuous `[].filter(canGrant)` would
|
|
625
|
+
// otherwise pass the authority check for any entry-gate-clearing
|
|
626
|
+
// bearer. The explicit empty-scopes guard must 403 instead.
|
|
627
|
+
const jti = "scopeless-target-jti";
|
|
628
|
+
recordTokenMint(db, {
|
|
629
|
+
jti,
|
|
630
|
+
createdVia: "cli_mint",
|
|
631
|
+
subject: userId,
|
|
632
|
+
clientId: "parachute-hub",
|
|
633
|
+
scopes: [],
|
|
634
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
635
|
+
});
|
|
636
|
+
expect(findTokenRowByJti(db, jti)?.scopes).toEqual([]);
|
|
637
|
+
|
|
638
|
+
const resp = await handleApiRevokeToken(
|
|
639
|
+
jsonRequest({ jti }, { authorization: `Bearer ${bearer}` }),
|
|
640
|
+
{ db, issuer: ISSUER },
|
|
641
|
+
);
|
|
642
|
+
expect(resp.status).toBe(403);
|
|
643
|
+
expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
|
|
644
|
+
// SECURITY: the scopeless token must NOT have been revoked.
|
|
645
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).toBeNull();
|
|
646
|
+
} finally {
|
|
647
|
+
db.close();
|
|
648
|
+
}
|
|
649
|
+
} finally {
|
|
650
|
+
h.cleanup();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("EMPTY-SCOPES: host:auth bearer CAN revoke a scopeless target → 200 (guard is non-host:auth-only)", async () => {
|
|
655
|
+
const h = makeHarness();
|
|
656
|
+
try {
|
|
657
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
658
|
+
try {
|
|
659
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
660
|
+
const jti = "scopeless-target-host-auth";
|
|
661
|
+
recordTokenMint(db, {
|
|
662
|
+
jti,
|
|
663
|
+
createdVia: "cli_mint",
|
|
664
|
+
subject: userId,
|
|
665
|
+
clientId: "parachute-hub",
|
|
666
|
+
scopes: [],
|
|
667
|
+
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
668
|
+
});
|
|
669
|
+
const resp = await handleApiRevokeToken(
|
|
670
|
+
jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
|
|
671
|
+
{ db, issuer: ISSUER },
|
|
672
|
+
);
|
|
673
|
+
expect(resp.status).toBe(200);
|
|
674
|
+
expect(findTokenRowByJti(db, jti)?.revokedAt).not.toBeNull();
|
|
675
|
+
} finally {
|
|
676
|
+
db.close();
|
|
677
|
+
}
|
|
678
|
+
} finally {
|
|
679
|
+
h.cleanup();
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test("JTI LENGTH GUARD: jti longer than the cap → 400 invalid_request", async () => {
|
|
684
|
+
const h = makeHarness();
|
|
685
|
+
try {
|
|
686
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
687
|
+
try {
|
|
688
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
689
|
+
const resp = await handleApiRevokeToken(
|
|
690
|
+
jsonRequest({ jti: "x".repeat(257) }, { authorization: `Bearer ${op.token}` }),
|
|
691
|
+
{ db, issuer: ISSUER },
|
|
692
|
+
);
|
|
693
|
+
expect(resp.status).toBe(400);
|
|
694
|
+
const body = (await resp.json()) as { error: string; error_description: string };
|
|
695
|
+
expect(body.error).toBe("invalid_request");
|
|
696
|
+
expect(body.error_description).toContain("256");
|
|
697
|
+
} finally {
|
|
698
|
+
db.close();
|
|
699
|
+
}
|
|
700
|
+
} finally {
|
|
701
|
+
h.cleanup();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
});
|
|
@@ -462,7 +462,7 @@ describe("handleDeleteUser", () => {
|
|
|
462
462
|
expect(list.users.map((u) => u.id)).toContain(userId);
|
|
463
463
|
});
|
|
464
464
|
|
|
465
|
-
test("
|
|
465
|
+
test("200 + revocation_lag_seconds deletes a non-first user and revokes their tokens", async () => {
|
|
466
466
|
const { bearer } = await makeAdminBearer();
|
|
467
467
|
// Create a second user (non-first) + mint a token on their behalf.
|
|
468
468
|
const second = await createUser(harness.db, "alice", "alice-strong-passphrase", {
|
|
@@ -497,7 +497,12 @@ describe("handleDeleteUser", () => {
|
|
|
497
497
|
second.id,
|
|
498
498
|
deps(),
|
|
499
499
|
);
|
|
500
|
-
|
|
500
|
+
// 200 + body (was a bare 204) so the SPA can warn about revocation lag —
|
|
501
|
+
// consistency with the reset-password path.
|
|
502
|
+
expect(res.status).toBe(200);
|
|
503
|
+
const body = (await res.json()) as { ok: boolean; revocation_lag_seconds: number };
|
|
504
|
+
expect(body.ok).toBe(true);
|
|
505
|
+
expect(body.revocation_lag_seconds).toBe(60);
|
|
501
506
|
|
|
502
507
|
// User row is gone.
|
|
503
508
|
const listRes = await handleListUsers(withBearer("/api/users", bearer), deps());
|
|
@@ -65,34 +65,159 @@ async function captureOutput(fn: () => Promise<number> | number): Promise<{
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
describe("parachute auth", () => {
|
|
68
|
-
test("2fa
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
test("2fa is hub-local — never forwards to parachute-vault", async () => {
|
|
69
|
+
// 2fa used to forward to the deprecated `parachute-vault 2fa` stub. As of
|
|
70
|
+
// hub#473 it's real hub-login TOTP, fully hub-local (hub.db). No subprocess.
|
|
71
|
+
const tmp = makeTmp();
|
|
72
|
+
try {
|
|
73
|
+
const db = openHubDb(tmp.dbPath);
|
|
74
|
+
await createUser(db, "owner", "owner-password-123");
|
|
75
|
+
db.close();
|
|
76
|
+
const { runner, calls } = makeRunner(0);
|
|
77
|
+
const out = await captureOutput(() =>
|
|
78
|
+
auth(["2fa", "status"], { runner, dbPath: tmp.dbPath }),
|
|
79
|
+
);
|
|
80
|
+
expect(out.code).toBe(0);
|
|
81
|
+
expect(calls).toEqual([]); // did NOT spawn parachute-vault
|
|
82
|
+
expect(out.stdout).toContain("Two-factor authentication: OFF");
|
|
83
|
+
} finally {
|
|
84
|
+
tmp.cleanup();
|
|
85
|
+
}
|
|
73
86
|
});
|
|
74
87
|
|
|
75
|
-
test("2fa
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
test("2fa status reports OFF for a fresh user, then ON after a CLI enroll round-trip", async () => {
|
|
89
|
+
const tmp = makeTmp();
|
|
90
|
+
try {
|
|
91
|
+
const db = openHubDb(tmp.dbPath);
|
|
92
|
+
await createUser(db, "owner", "owner-password-123");
|
|
93
|
+
db.close();
|
|
94
|
+
|
|
95
|
+
// status → OFF
|
|
96
|
+
const off = await captureOutput(() =>
|
|
97
|
+
auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
98
|
+
);
|
|
99
|
+
expect(off.code).toBe(0);
|
|
100
|
+
expect(off.stdout).toContain("OFF");
|
|
101
|
+
|
|
102
|
+
// enroll requires a confirm code — drive the readLine seam with the
|
|
103
|
+
// live TOTP code generated from the secret the command prints.
|
|
104
|
+
const { generateTotpSecret } = await import("../totp.ts");
|
|
105
|
+
// We can't intercept the random secret the command mints, so instead
|
|
106
|
+
// exercise the store directly to assert the ON path is reachable, then
|
|
107
|
+
// assert the CLI status reflects it. (The handler-level enroll round
|
|
108
|
+
// trip is covered in two-factor.test.ts against the real secret.)
|
|
109
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
110
|
+
const { persistEnrollment } = await import("../two-factor-store.ts");
|
|
111
|
+
const u = listUsers(db2)[0]!;
|
|
112
|
+
const { secret } = generateTotpSecret(u.username);
|
|
113
|
+
await persistEnrollment(db2, u.id, secret);
|
|
114
|
+
db2.close();
|
|
115
|
+
|
|
116
|
+
const on = await captureOutput(() =>
|
|
117
|
+
auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
118
|
+
);
|
|
119
|
+
expect(on.code).toBe(0);
|
|
120
|
+
expect(on.stdout).toContain("ON");
|
|
121
|
+
expect(on.stdout).toContain("backup_codes");
|
|
122
|
+
|
|
123
|
+
// disenroll clears it.
|
|
124
|
+
const dis = await captureOutput(() =>
|
|
125
|
+
auth(["2fa", "disenroll"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
126
|
+
);
|
|
127
|
+
expect(dis.code).toBe(0);
|
|
128
|
+
expect(dis.stdout).toContain("Turned off");
|
|
129
|
+
|
|
130
|
+
const off2 = await captureOutput(() =>
|
|
131
|
+
auth(["2fa", "status"], { dbPath: tmp.dbPath, isInteractive: () => false }),
|
|
132
|
+
);
|
|
133
|
+
expect(off2.stdout).toContain("OFF");
|
|
134
|
+
} finally {
|
|
135
|
+
tmp.cleanup();
|
|
136
|
+
}
|
|
80
137
|
});
|
|
81
138
|
|
|
82
|
-
test("
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
139
|
+
test("2fa enroll confirms the printed secret against a live code, prints backup codes", async () => {
|
|
140
|
+
const tmp = makeTmp();
|
|
141
|
+
try {
|
|
142
|
+
const db = openHubDb(tmp.dbPath);
|
|
143
|
+
await createUser(db, "owner", "owner-password-123");
|
|
144
|
+
db.close();
|
|
145
|
+
|
|
146
|
+
// Single console.log interception that BOTH accumulates stdout and lets
|
|
147
|
+
// the readLine seam read the secret the command just printed. (Avoids
|
|
148
|
+
// nesting two console.log replacements.)
|
|
149
|
+
const OTPAuth = await import("otpauth");
|
|
150
|
+
const origLog = console.log;
|
|
151
|
+
let stdout = "";
|
|
152
|
+
let capturedSecret = "";
|
|
153
|
+
console.log = (...a: unknown[]) => {
|
|
154
|
+
const line = a.map(String).join(" ");
|
|
155
|
+
stdout += `${line}\n`;
|
|
156
|
+
const m = line.match(/secret key:\s+([A-Z2-7]+)/);
|
|
157
|
+
if (m) capturedSecret = m[1]!;
|
|
158
|
+
};
|
|
159
|
+
let code = "";
|
|
160
|
+
let exitCode = 0;
|
|
161
|
+
try {
|
|
162
|
+
exitCode = await auth(["2fa", "enroll"], {
|
|
163
|
+
dbPath: tmp.dbPath,
|
|
164
|
+
isInteractive: () => true,
|
|
165
|
+
readLine: async () => {
|
|
166
|
+
// The secret has been printed by the time the prompt fires.
|
|
167
|
+
const totp = new OTPAuth.TOTP({
|
|
168
|
+
issuer: "Parachute Hub",
|
|
169
|
+
label: "owner",
|
|
170
|
+
algorithm: "SHA1",
|
|
171
|
+
digits: 6,
|
|
172
|
+
period: 30,
|
|
173
|
+
secret: OTPAuth.Secret.fromBase32(capturedSecret),
|
|
174
|
+
});
|
|
175
|
+
code = totp.generate();
|
|
176
|
+
return code;
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
console.log = origLog;
|
|
181
|
+
}
|
|
182
|
+
expect(exitCode).toBe(0);
|
|
183
|
+
expect(capturedSecret.length).toBeGreaterThan(0);
|
|
184
|
+
expect(stdout).toContain("now ON");
|
|
185
|
+
// 10 backup codes printed (hyphenated form).
|
|
186
|
+
const backupLines = stdout.split("\n").filter((l) => /^ {2}[a-z2-9]{5}-[a-z2-9]{5}$/.test(l));
|
|
187
|
+
expect(backupLines.length).toBe(10);
|
|
188
|
+
expect(code.length).toBe(6);
|
|
189
|
+
// The persisted state reflects the enrollment: the captured secret is
|
|
190
|
+
// now stored on the user row. (We don't re-verify `code` — the enroll
|
|
191
|
+
// confirm consumed it via the replay cache, by design.)
|
|
192
|
+
const db2 = openHubDb(tmp.dbPath);
|
|
193
|
+
const { getTotpState } = await import("../two-factor-store.ts");
|
|
194
|
+
const uid = listUsers(db2)[0]!.id;
|
|
195
|
+
const persisted = getTotpState(db2, uid);
|
|
196
|
+
expect(persisted.secret).toBe(capturedSecret);
|
|
197
|
+
expect(persisted.backupCodes.length).toBe(10);
|
|
198
|
+
db2.close();
|
|
199
|
+
} finally {
|
|
200
|
+
tmp.cleanup();
|
|
201
|
+
}
|
|
86
202
|
});
|
|
87
203
|
|
|
88
|
-
test("
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
204
|
+
test("2fa enroll refuses to re-enroll when already on", async () => {
|
|
205
|
+
const tmp = makeTmp();
|
|
206
|
+
try {
|
|
207
|
+
const db = openHubDb(tmp.dbPath);
|
|
208
|
+
const u = await createUser(db, "owner", "owner-password-123");
|
|
209
|
+
const { generateTotpSecret } = await import("../totp.ts");
|
|
210
|
+
const { persistEnrollment } = await import("../two-factor-store.ts");
|
|
211
|
+
await persistEnrollment(db, u.id, generateTotpSecret("owner").secret);
|
|
212
|
+
db.close();
|
|
213
|
+
const out = await captureOutput(() =>
|
|
214
|
+
auth(["2fa", "enroll"], { dbPath: tmp.dbPath, isInteractive: () => true }),
|
|
215
|
+
);
|
|
216
|
+
expect(out.code).toBe(1);
|
|
217
|
+
expect(out.stderr).toContain("already enabled");
|
|
218
|
+
} finally {
|
|
219
|
+
tmp.cleanup();
|
|
220
|
+
}
|
|
96
221
|
});
|
|
97
222
|
|
|
98
223
|
test("set-password no longer forwards to vault", async () => {
|
|
@@ -142,23 +267,25 @@ describe("authHelp", () => {
|
|
|
142
267
|
test("lists every blessed subcommand", () => {
|
|
143
268
|
expect(h).toContain("parachute auth set-password");
|
|
144
269
|
expect(h).toContain("parachute auth list-users");
|
|
145
|
-
expect(h).toContain("parachute auth 2fa
|
|
146
|
-
expect(h).toContain("parachute auth 2fa enroll");
|
|
147
|
-
expect(h).toContain("parachute auth 2fa disable");
|
|
148
|
-
expect(h).toContain("parachute auth 2fa backup-codes");
|
|
270
|
+
expect(h).toContain("parachute auth 2fa");
|
|
149
271
|
expect(h).toContain("parachute auth rotate-key");
|
|
150
272
|
});
|
|
151
273
|
|
|
274
|
+
test("2fa help documents the real hub-login TOTP subcommands (#473)", () => {
|
|
275
|
+
expect(h).toContain("#473");
|
|
276
|
+
// Real enroll / disenroll subcommands are now advertised.
|
|
277
|
+
expect(h).toContain("2fa enroll");
|
|
278
|
+
expect(h).toContain("2fa disenroll");
|
|
279
|
+
expect(h).toContain("otpauth://");
|
|
280
|
+
expect(h).toContain("backup codes");
|
|
281
|
+
});
|
|
282
|
+
|
|
152
283
|
test("set-password help mentions the new flags + hub-local home", () => {
|
|
153
284
|
expect(h).toContain("--username");
|
|
154
285
|
expect(h).toContain("--allow-multi");
|
|
155
286
|
expect(h).toContain("hub.db");
|
|
156
287
|
});
|
|
157
288
|
|
|
158
|
-
test("mentions the vault-install hint", () => {
|
|
159
|
-
expect(h).toContain("parachute install vault");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
289
|
test("rotate-key explains the 24h JWKS retention", () => {
|
|
163
290
|
expect(h).toContain("jwks.json");
|
|
164
291
|
// "24" + "hours" may be split by line wrap; check both pieces.
|