@openparachute/hub 0.5.2 → 0.5.9-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -556,6 +556,103 @@ describe("parachute auth rotate-operator", () => {
|
|
|
556
556
|
tmp.cleanup();
|
|
557
557
|
}
|
|
558
558
|
});
|
|
559
|
+
|
|
560
|
+
// closes #213 — `--scope-set` flag.
|
|
561
|
+
test("--scope-set=start mints with parachute:host:start only", async () => {
|
|
562
|
+
const tmp = makeTmp();
|
|
563
|
+
try {
|
|
564
|
+
const deps: AuthDeps = {
|
|
565
|
+
dbPath: tmp.dbPath,
|
|
566
|
+
configDir: tmp.dir,
|
|
567
|
+
isInteractive: () => false,
|
|
568
|
+
};
|
|
569
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
570
|
+
const { code, stdout } = await captureOutput(() =>
|
|
571
|
+
auth(["rotate-operator", "--scope-set", "start"], deps),
|
|
572
|
+
);
|
|
573
|
+
expect(code).toBe(0);
|
|
574
|
+
expect(stdout).toContain("scope_set: start");
|
|
575
|
+
const onDisk = await readOperatorTokenFile(tmp.dir);
|
|
576
|
+
expect(onDisk).not.toBeNull();
|
|
577
|
+
const db = openHubDb(tmp.dbPath);
|
|
578
|
+
try {
|
|
579
|
+
const validated = await validateAccessToken(db, onDisk!, "http://127.0.0.1:1939");
|
|
580
|
+
expect(validated.payload.scope).toBe("parachute:host:start");
|
|
581
|
+
} finally {
|
|
582
|
+
db.close();
|
|
583
|
+
}
|
|
584
|
+
} finally {
|
|
585
|
+
tmp.cleanup();
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("--scope-set=admin (default) mints the full admin set", async () => {
|
|
590
|
+
const tmp = makeTmp();
|
|
591
|
+
try {
|
|
592
|
+
const deps: AuthDeps = {
|
|
593
|
+
dbPath: tmp.dbPath,
|
|
594
|
+
configDir: tmp.dir,
|
|
595
|
+
isInteractive: () => false,
|
|
596
|
+
};
|
|
597
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
598
|
+
const { code, stdout } = await captureOutput(() => auth(["rotate-operator"], deps));
|
|
599
|
+
expect(code).toBe(0);
|
|
600
|
+
expect(stdout).toContain("scope_set: admin");
|
|
601
|
+
const onDisk = await readOperatorTokenFile(tmp.dir);
|
|
602
|
+
const db = openHubDb(tmp.dbPath);
|
|
603
|
+
try {
|
|
604
|
+
const validated = await validateAccessToken(db, onDisk!, "http://127.0.0.1:1939");
|
|
605
|
+
const scopes = String(validated.payload.scope ?? "").split(" ");
|
|
606
|
+
expect(scopes).toContain("hub:admin");
|
|
607
|
+
expect(scopes).toContain("parachute:host:admin");
|
|
608
|
+
expect(scopes).toContain("vault:admin");
|
|
609
|
+
} finally {
|
|
610
|
+
db.close();
|
|
611
|
+
}
|
|
612
|
+
} finally {
|
|
613
|
+
tmp.cleanup();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("--scope-set=bogus rejected with usage message", async () => {
|
|
618
|
+
const tmp = makeTmp();
|
|
619
|
+
try {
|
|
620
|
+
const deps: AuthDeps = {
|
|
621
|
+
dbPath: tmp.dbPath,
|
|
622
|
+
configDir: tmp.dir,
|
|
623
|
+
isInteractive: () => false,
|
|
624
|
+
};
|
|
625
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
626
|
+
const { code, stderr } = await captureOutput(() =>
|
|
627
|
+
auth(["rotate-operator", "--scope-set", "wallet"], deps),
|
|
628
|
+
);
|
|
629
|
+
expect(code).toBe(1);
|
|
630
|
+
expect(stderr).toContain("--scope-set must be one of");
|
|
631
|
+
expect(stderr).toContain("install");
|
|
632
|
+
expect(stderr).toContain("admin");
|
|
633
|
+
} finally {
|
|
634
|
+
tmp.cleanup();
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("unknown flag is rejected", async () => {
|
|
639
|
+
const tmp = makeTmp();
|
|
640
|
+
try {
|
|
641
|
+
const deps: AuthDeps = {
|
|
642
|
+
dbPath: tmp.dbPath,
|
|
643
|
+
configDir: tmp.dir,
|
|
644
|
+
isInteractive: () => false,
|
|
645
|
+
};
|
|
646
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
647
|
+
const { code, stderr } = await captureOutput(() =>
|
|
648
|
+
auth(["rotate-operator", "--bogus"], deps),
|
|
649
|
+
);
|
|
650
|
+
expect(code).toBe(1);
|
|
651
|
+
expect(stderr).toContain("unknown flag");
|
|
652
|
+
} finally {
|
|
653
|
+
tmp.cleanup();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
559
656
|
});
|
|
560
657
|
|
|
561
658
|
// closes #74 — the operator's surface for the DCR approval gate. The CLI
|
|
@@ -863,7 +960,8 @@ describe("parachute auth mint-token", () => {
|
|
|
863
960
|
}
|
|
864
961
|
});
|
|
865
962
|
|
|
866
|
-
|
|
963
|
+
// closes #213 — auto-rotation banner must not leak into stdout (pipe purity).
|
|
964
|
+
test("operator token within 7d of expiry: auto-rotates, banner on stderr only", async () => {
|
|
867
965
|
const tmp = makeTmp();
|
|
868
966
|
try {
|
|
869
967
|
const deps: AuthDeps = {
|
|
@@ -873,40 +971,172 @@ describe("parachute auth mint-token", () => {
|
|
|
873
971
|
};
|
|
874
972
|
// Bootstrap: set-password to seed the user + signing key.
|
|
875
973
|
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
|
|
974
|
+
// Overwrite operator.token with one that's within 7d of expiry — the
|
|
975
|
+
// auto-rotation path should fire on the next mint-token invocation.
|
|
976
|
+
const { issueOperatorToken } = await import("../operator-token.ts");
|
|
879
977
|
const db = openHubDb(tmp.dbPath);
|
|
880
|
-
|
|
978
|
+
const originalOnDisk = await readOperatorTokenFile(tmp.dir);
|
|
881
979
|
try {
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
sub: owner.id,
|
|
885
|
-
scopes: ["scribe:transcribe"],
|
|
886
|
-
audience: "scribe",
|
|
887
|
-
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
980
|
+
await issueOperatorToken(db, listUsers(db)[0]!.id, {
|
|
981
|
+
dir: tmp.dir,
|
|
888
982
|
issuer: "http://127.0.0.1:1939",
|
|
889
|
-
ttlSeconds:
|
|
983
|
+
ttlSeconds: 24 * 60 * 60,
|
|
890
984
|
});
|
|
891
|
-
narrow = signed.token;
|
|
892
985
|
} finally {
|
|
893
986
|
db.close();
|
|
894
987
|
}
|
|
895
|
-
await writeOperatorTokenFile(narrow, tmp.dir);
|
|
896
988
|
|
|
989
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
990
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
991
|
+
);
|
|
992
|
+
expect(code).toBe(0);
|
|
993
|
+
// The minted JWT is the only thing on stdout (pipe purity).
|
|
994
|
+
const token = stdout.trim();
|
|
995
|
+
expect(token.split(".").length).toBe(3);
|
|
996
|
+
expect(stdout).toBe(`${token}\n`);
|
|
997
|
+
// Auto-rotation banner went to stderr.
|
|
998
|
+
expect(stderr).toContain("auto-rotated");
|
|
999
|
+
expect(stderr).toContain("scope_set=admin");
|
|
1000
|
+
// The on-disk operator.token was replaced.
|
|
1001
|
+
const after = await readOperatorTokenFile(tmp.dir);
|
|
1002
|
+
expect(after).not.toBeNull();
|
|
1003
|
+
expect(after).not.toBe(originalOnDisk);
|
|
1004
|
+
} finally {
|
|
1005
|
+
tmp.cleanup();
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// Helper: stash a token with the chosen scopes at operator.token, returning
|
|
1010
|
+
// the deps bag so the caller can immediately invoke mint-token. Used by
|
|
1011
|
+
// every gating-scope test below to exercise the gate against a known
|
|
1012
|
+
// narrow / wide token without going through `rotate-operator` (which would
|
|
1013
|
+
// always mint admin-set).
|
|
1014
|
+
//
|
|
1015
|
+
// TTL is 30d (well beyond the 7d auto-rotation window) so the token
|
|
1016
|
+
// survives the next mint-token call without being silently swapped for a
|
|
1017
|
+
// fresh admin-set token by the auto-rotation path. Without this, narrow
|
|
1018
|
+
// tokens would auto-rotate to admin and our gate tests would all see the
|
|
1019
|
+
// post-rotation token, defeating the test entirely.
|
|
1020
|
+
async function bootstrapWithOperatorScopes(
|
|
1021
|
+
tmp: { dir: string; dbPath: string; cleanup: () => void },
|
|
1022
|
+
scopes: readonly string[],
|
|
1023
|
+
): Promise<AuthDeps> {
|
|
1024
|
+
const deps: AuthDeps = {
|
|
1025
|
+
dbPath: tmp.dbPath,
|
|
1026
|
+
configDir: tmp.dir,
|
|
1027
|
+
isInteractive: () => false,
|
|
1028
|
+
};
|
|
1029
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1030
|
+
const db = openHubDb(tmp.dbPath);
|
|
1031
|
+
let token: string;
|
|
1032
|
+
try {
|
|
1033
|
+
const owner = listUsers(db)[0]!;
|
|
1034
|
+
const signed = await signAccessToken(db, {
|
|
1035
|
+
sub: owner.id,
|
|
1036
|
+
scopes: [...scopes],
|
|
1037
|
+
audience: "operator",
|
|
1038
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
1039
|
+
issuer: "http://127.0.0.1:1939",
|
|
1040
|
+
ttlSeconds: 30 * 24 * 60 * 60,
|
|
1041
|
+
});
|
|
1042
|
+
token = signed.token;
|
|
1043
|
+
} finally {
|
|
1044
|
+
db.close();
|
|
1045
|
+
}
|
|
1046
|
+
await writeOperatorTokenFile(token, tmp.dir);
|
|
1047
|
+
return deps;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// hub#222: gate widened from `hub:admin` to `parachute:host:auth`. The
|
|
1051
|
+
// following tests pin the new behaviour:
|
|
1052
|
+
// - admin scope-set (which carries both) still succeeds (regression);
|
|
1053
|
+
// - `auth` scope-set (carries only `:host:auth`) NOW succeeds (gain);
|
|
1054
|
+
// - other narrow scope-sets (vault/install/etc.) still rejected;
|
|
1055
|
+
// - error message updated to name the new gate.
|
|
1056
|
+
|
|
1057
|
+
test("operator token with `auth` scope-set (parachute:host:auth only) mints successfully (hub#222)", async () => {
|
|
1058
|
+
const tmp = makeTmp();
|
|
1059
|
+
try {
|
|
1060
|
+
const deps = await bootstrapWithOperatorScopes(tmp, ["parachute:host:auth"]);
|
|
1061
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
1062
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
1063
|
+
);
|
|
1064
|
+
expect(code).toBe(0);
|
|
1065
|
+
// Pipe purity: stdout is the JWT, stderr empty.
|
|
1066
|
+
const token = stdout.trim();
|
|
1067
|
+
expect(token.split(".").length).toBe(3);
|
|
1068
|
+
expect(stdout).toBe(`${token}\n`);
|
|
1069
|
+
expect(stderr).toBe("");
|
|
1070
|
+
} finally {
|
|
1071
|
+
tmp.cleanup();
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test("operator token with admin scope-set still mints (regression — admin includes :host:auth as superset)", async () => {
|
|
1076
|
+
const tmp = makeTmp();
|
|
1077
|
+
try {
|
|
1078
|
+
// The full admin scope-set carries both `hub:admin` and `parachute:host:auth`.
|
|
1079
|
+
const deps = await bootstrapWithOperatorScopes(tmp, [
|
|
1080
|
+
"hub:admin",
|
|
1081
|
+
"parachute:host:auth",
|
|
1082
|
+
"vault:admin",
|
|
1083
|
+
]);
|
|
1084
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1085
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
1086
|
+
);
|
|
1087
|
+
expect(code).toBe(0);
|
|
1088
|
+
expect(stdout.trim().split(".").length).toBe(3);
|
|
1089
|
+
} finally {
|
|
1090
|
+
tmp.cleanup();
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
test("operator token without parachute:host:auth is rejected (no token emitted)", async () => {
|
|
1095
|
+
const tmp = makeTmp();
|
|
1096
|
+
try {
|
|
1097
|
+
// Narrow non-auth token (resembles what someone might stash by mistake,
|
|
1098
|
+
// or a `--scope-set vault` operator token from rotate-operator).
|
|
1099
|
+
const deps = await bootstrapWithOperatorScopes(tmp, ["scribe:transcribe"]);
|
|
897
1100
|
const { code, stdout, stderr } = await captureOutput(() =>
|
|
898
1101
|
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
899
1102
|
);
|
|
900
1103
|
expect(code).toBe(1);
|
|
901
|
-
expect(stderr).toContain("lacks
|
|
1104
|
+
expect(stderr).toContain("lacks parachute:host:auth scope");
|
|
902
1105
|
expect(stderr).toContain("rotate-operator");
|
|
903
|
-
// Purity: no token written to stdout.
|
|
904
1106
|
expect(stdout).toBe("");
|
|
905
1107
|
} finally {
|
|
906
1108
|
tmp.cleanup();
|
|
907
1109
|
}
|
|
908
1110
|
});
|
|
909
1111
|
|
|
1112
|
+
test("`vault` scope-set is rejected (regression — narrow scope-sets still can't mint)", async () => {
|
|
1113
|
+
const tmp = makeTmp();
|
|
1114
|
+
try {
|
|
1115
|
+
const deps = await bootstrapWithOperatorScopes(tmp, ["parachute:host:vault"]);
|
|
1116
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1117
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
1118
|
+
);
|
|
1119
|
+
expect(code).toBe(1);
|
|
1120
|
+
expect(stderr).toContain("lacks parachute:host:auth scope");
|
|
1121
|
+
} finally {
|
|
1122
|
+
tmp.cleanup();
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("`install` scope-set is rejected (regression — narrow scope-sets still can't mint)", async () => {
|
|
1127
|
+
const tmp = makeTmp();
|
|
1128
|
+
try {
|
|
1129
|
+
const deps = await bootstrapWithOperatorScopes(tmp, ["parachute:host:install", "vault:read"]);
|
|
1130
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1131
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
1132
|
+
);
|
|
1133
|
+
expect(code).toBe(1);
|
|
1134
|
+
expect(stderr).toContain("lacks parachute:host:auth scope");
|
|
1135
|
+
} finally {
|
|
1136
|
+
tmp.cleanup();
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
910
1140
|
test("named vault scope infers aud=vault.<name>", async () => {
|
|
911
1141
|
const tmp = makeTmp();
|
|
912
1142
|
try {
|
|
@@ -1144,4 +1374,438 @@ describe("parachute auth mint-token", () => {
|
|
|
1144
1374
|
tmp.cleanup();
|
|
1145
1375
|
}
|
|
1146
1376
|
});
|
|
1377
|
+
|
|
1378
|
+
// closes #212 Phase 1 — registry write, --permissions, --expires-in,
|
|
1379
|
+
// --ttl deprecation notice.
|
|
1380
|
+
test("every successful mint writes a tokens registry row (created_via=cli_mint)", async () => {
|
|
1381
|
+
const tmp = makeTmp();
|
|
1382
|
+
try {
|
|
1383
|
+
const deps: AuthDeps = {
|
|
1384
|
+
dbPath: tmp.dbPath,
|
|
1385
|
+
configDir: tmp.dir,
|
|
1386
|
+
isInteractive: () => false,
|
|
1387
|
+
};
|
|
1388
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1389
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1390
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
1391
|
+
);
|
|
1392
|
+
expect(code).toBe(0);
|
|
1393
|
+
const token = stdout.trim();
|
|
1394
|
+
const db = openHubDb(tmp.dbPath);
|
|
1395
|
+
try {
|
|
1396
|
+
const validated = await validateAccessToken(db, token);
|
|
1397
|
+
const jti = validated.payload.jti as string;
|
|
1398
|
+
const row = db
|
|
1399
|
+
.query<{ jti: string; created_via: string; subject: string | null }, [string]>(
|
|
1400
|
+
"SELECT jti, created_via, subject FROM tokens WHERE jti = ?",
|
|
1401
|
+
)
|
|
1402
|
+
.get(jti);
|
|
1403
|
+
expect(row).not.toBeNull();
|
|
1404
|
+
expect(row?.created_via).toBe("cli_mint");
|
|
1405
|
+
// Default subject = operator's sub (the hub user id).
|
|
1406
|
+
expect(typeof row?.subject).toBe("string");
|
|
1407
|
+
expect(row?.subject?.length).toBeGreaterThan(0);
|
|
1408
|
+
} finally {
|
|
1409
|
+
db.close();
|
|
1410
|
+
}
|
|
1411
|
+
} finally {
|
|
1412
|
+
tmp.cleanup();
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
test("--permissions JSON object round-trips into JWT + registry row", async () => {
|
|
1417
|
+
const tmp = makeTmp();
|
|
1418
|
+
try {
|
|
1419
|
+
const deps: AuthDeps = {
|
|
1420
|
+
dbPath: tmp.dbPath,
|
|
1421
|
+
configDir: tmp.dir,
|
|
1422
|
+
isInteractive: () => false,
|
|
1423
|
+
};
|
|
1424
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1425
|
+
const permissions = '{"vault":{"default":{"write_tags":["health"]}}}';
|
|
1426
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1427
|
+
auth(["mint-token", "--scope", "vault:default:write", "--permissions", permissions], deps),
|
|
1428
|
+
);
|
|
1429
|
+
expect(code).toBe(0);
|
|
1430
|
+
const token = stdout.trim();
|
|
1431
|
+
const db = openHubDb(tmp.dbPath);
|
|
1432
|
+
try {
|
|
1433
|
+
const validated = await validateAccessToken(db, token);
|
|
1434
|
+
expect(validated.payload.permissions).toEqual({
|
|
1435
|
+
vault: { default: { write_tags: ["health"] } },
|
|
1436
|
+
});
|
|
1437
|
+
const jti = validated.payload.jti as string;
|
|
1438
|
+
const row = db
|
|
1439
|
+
.query<{ permissions: string }, [string]>("SELECT permissions FROM tokens WHERE jti = ?")
|
|
1440
|
+
.get(jti);
|
|
1441
|
+
expect(JSON.parse(row!.permissions)).toEqual({
|
|
1442
|
+
vault: { default: { write_tags: ["health"] } },
|
|
1443
|
+
});
|
|
1444
|
+
} finally {
|
|
1445
|
+
db.close();
|
|
1446
|
+
}
|
|
1447
|
+
} finally {
|
|
1448
|
+
tmp.cleanup();
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
test("--permissions with malformed JSON is rejected", async () => {
|
|
1453
|
+
const tmp = makeTmp();
|
|
1454
|
+
try {
|
|
1455
|
+
const deps: AuthDeps = {
|
|
1456
|
+
dbPath: tmp.dbPath,
|
|
1457
|
+
configDir: tmp.dir,
|
|
1458
|
+
isInteractive: () => false,
|
|
1459
|
+
};
|
|
1460
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1461
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1462
|
+
auth(["mint-token", "--scope", "vault:read", "--permissions", "{not-json}"], deps),
|
|
1463
|
+
);
|
|
1464
|
+
expect(code).toBe(1);
|
|
1465
|
+
expect(stderr).toContain("not valid JSON");
|
|
1466
|
+
} finally {
|
|
1467
|
+
tmp.cleanup();
|
|
1468
|
+
}
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
test("--permissions with non-object JSON (array) is rejected", async () => {
|
|
1472
|
+
const tmp = makeTmp();
|
|
1473
|
+
try {
|
|
1474
|
+
const deps: AuthDeps = {
|
|
1475
|
+
dbPath: tmp.dbPath,
|
|
1476
|
+
configDir: tmp.dir,
|
|
1477
|
+
isInteractive: () => false,
|
|
1478
|
+
};
|
|
1479
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1480
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1481
|
+
auth(["mint-token", "--scope", "vault:read", "--permissions", "[1,2,3]"], deps),
|
|
1482
|
+
);
|
|
1483
|
+
expect(code).toBe(1);
|
|
1484
|
+
expect(stderr).toContain("must be a JSON object");
|
|
1485
|
+
} finally {
|
|
1486
|
+
tmp.cleanup();
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
test("--expires-in (canonical) sets the JWT TTL in seconds", async () => {
|
|
1491
|
+
const tmp = makeTmp();
|
|
1492
|
+
try {
|
|
1493
|
+
const deps: AuthDeps = {
|
|
1494
|
+
dbPath: tmp.dbPath,
|
|
1495
|
+
configDir: tmp.dir,
|
|
1496
|
+
isInteractive: () => false,
|
|
1497
|
+
};
|
|
1498
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1499
|
+
const { code, stdout } = await captureOutput(() =>
|
|
1500
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--expires-in", "7200"], deps),
|
|
1501
|
+
);
|
|
1502
|
+
expect(code).toBe(0);
|
|
1503
|
+
const token = stdout.trim();
|
|
1504
|
+
const db = openHubDb(tmp.dbPath);
|
|
1505
|
+
try {
|
|
1506
|
+
const validated = await validateAccessToken(db, token);
|
|
1507
|
+
const exp = validated.payload.exp as number;
|
|
1508
|
+
const iat = validated.payload.iat as number;
|
|
1509
|
+
expect(exp - iat).toBe(7200);
|
|
1510
|
+
} finally {
|
|
1511
|
+
db.close();
|
|
1512
|
+
}
|
|
1513
|
+
} finally {
|
|
1514
|
+
tmp.cleanup();
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
test("--expires-in with non-integer value rejected", async () => {
|
|
1519
|
+
const tmp = makeTmp();
|
|
1520
|
+
try {
|
|
1521
|
+
const deps: AuthDeps = {
|
|
1522
|
+
dbPath: tmp.dbPath,
|
|
1523
|
+
configDir: tmp.dir,
|
|
1524
|
+
isInteractive: () => false,
|
|
1525
|
+
};
|
|
1526
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1527
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1528
|
+
auth(["mint-token", "--scope", "vault:read", "--expires-in", "1d"], deps),
|
|
1529
|
+
);
|
|
1530
|
+
expect(code).toBe(1);
|
|
1531
|
+
expect(stderr).toContain("integer seconds count");
|
|
1532
|
+
} finally {
|
|
1533
|
+
tmp.cleanup();
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
test("--expires-in over 365d cap rejected", async () => {
|
|
1538
|
+
const tmp = makeTmp();
|
|
1539
|
+
try {
|
|
1540
|
+
const deps: AuthDeps = {
|
|
1541
|
+
dbPath: tmp.dbPath,
|
|
1542
|
+
configDir: tmp.dir,
|
|
1543
|
+
isInteractive: () => false,
|
|
1544
|
+
};
|
|
1545
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1546
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1547
|
+
auth(["mint-token", "--scope", "vault:read", "--expires-in", String(366 * 86400)], deps),
|
|
1548
|
+
);
|
|
1549
|
+
expect(code).toBe(1);
|
|
1550
|
+
expect(stderr).toContain("365d cap");
|
|
1551
|
+
} finally {
|
|
1552
|
+
tmp.cleanup();
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test("--ttl emits deprecation notice on stderr but still works", async () => {
|
|
1557
|
+
const tmp = makeTmp();
|
|
1558
|
+
try {
|
|
1559
|
+
const deps: AuthDeps = {
|
|
1560
|
+
dbPath: tmp.dbPath,
|
|
1561
|
+
configDir: tmp.dir,
|
|
1562
|
+
isInteractive: () => false,
|
|
1563
|
+
};
|
|
1564
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1565
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
1566
|
+
auth(["mint-token", "--scope", "scribe:transcribe", "--ttl", "1h"], deps),
|
|
1567
|
+
);
|
|
1568
|
+
expect(code).toBe(0);
|
|
1569
|
+
expect(stdout.trim().split(".").length).toBe(3);
|
|
1570
|
+
expect(stderr).toContain("--ttl is deprecated");
|
|
1571
|
+
expect(stderr).toContain("--expires-in");
|
|
1572
|
+
expect(stderr).toContain("0.6.0");
|
|
1573
|
+
} finally {
|
|
1574
|
+
tmp.cleanup();
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
// closes #215 reviewer F1 — privilege-diffusion guard on the CLI mint path.
|
|
1579
|
+
test("CLI mint-token rejects parachute:host:auth (non-requestable scope)", async () => {
|
|
1580
|
+
const tmp = makeTmp();
|
|
1581
|
+
try {
|
|
1582
|
+
const deps: AuthDeps = {
|
|
1583
|
+
dbPath: tmp.dbPath,
|
|
1584
|
+
configDir: tmp.dir,
|
|
1585
|
+
isInteractive: () => false,
|
|
1586
|
+
};
|
|
1587
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1588
|
+
const { code, stdout, stderr } = await captureOutput(() =>
|
|
1589
|
+
auth(["mint-token", "--scope", "parachute:host:auth"], deps),
|
|
1590
|
+
);
|
|
1591
|
+
expect(code).toBe(1);
|
|
1592
|
+
expect(stderr).toContain("not requestable");
|
|
1593
|
+
expect(stderr).toContain("parachute:host:auth");
|
|
1594
|
+
// No token leaked to stdout.
|
|
1595
|
+
expect(stdout).toBe("");
|
|
1596
|
+
} finally {
|
|
1597
|
+
tmp.cleanup();
|
|
1598
|
+
}
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
test("passing both --ttl and --expires-in is an error", async () => {
|
|
1602
|
+
const tmp = makeTmp();
|
|
1603
|
+
try {
|
|
1604
|
+
const deps: AuthDeps = {
|
|
1605
|
+
dbPath: tmp.dbPath,
|
|
1606
|
+
configDir: tmp.dir,
|
|
1607
|
+
isInteractive: () => false,
|
|
1608
|
+
};
|
|
1609
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1610
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1611
|
+
auth(["mint-token", "--scope", "vault:read", "--ttl", "1h", "--expires-in", "3600"], deps),
|
|
1612
|
+
);
|
|
1613
|
+
expect(code).toBe(1);
|
|
1614
|
+
expect(stderr).toContain("--ttl");
|
|
1615
|
+
expect(stderr).toContain("--expires-in");
|
|
1616
|
+
} finally {
|
|
1617
|
+
tmp.cleanup();
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
describe("parachute auth revoke-token", () => {
|
|
1623
|
+
// Each test mints a fresh token via mint-token and then revokes it. Going
|
|
1624
|
+
// through the public mint surface (rather than calling signAccessToken +
|
|
1625
|
+
// recordTokenMint directly) keeps these tests honest about the contract:
|
|
1626
|
+
// mint writes a registry row, revoke-token flips its bit, and a future
|
|
1627
|
+
// round-trip through validateAccessToken would reject it. The Phase 4
|
|
1628
|
+
// RS-side enforcement is exercised in scope-guard's own integration suite.
|
|
1629
|
+
|
|
1630
|
+
async function mintAJti(deps: AuthDeps): Promise<string> {
|
|
1631
|
+
const { stdout } = await captureOutput(() =>
|
|
1632
|
+
auth(["mint-token", "--scope", "scribe:transcribe"], deps),
|
|
1633
|
+
);
|
|
1634
|
+
const token = stdout.trim();
|
|
1635
|
+
// Decode the unverified payload to recover the jti — every mint stamps one.
|
|
1636
|
+
const [, payloadB64] = token.split(".");
|
|
1637
|
+
const payload = JSON.parse(Buffer.from(payloadB64!, "base64url").toString("utf8")) as {
|
|
1638
|
+
jti: string;
|
|
1639
|
+
};
|
|
1640
|
+
return payload.jti;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
test("missing jti positional is a usage error", async () => {
|
|
1644
|
+
const tmp = makeTmp();
|
|
1645
|
+
try {
|
|
1646
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1647
|
+
auth(["revoke-token"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
|
|
1648
|
+
);
|
|
1649
|
+
expect(code).toBe(1);
|
|
1650
|
+
expect(stderr).toContain("missing jti argument");
|
|
1651
|
+
} finally {
|
|
1652
|
+
tmp.cleanup();
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
test("unexpected flag is a usage error", async () => {
|
|
1657
|
+
const tmp = makeTmp();
|
|
1658
|
+
try {
|
|
1659
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1660
|
+
auth(["revoke-token", "--reason", "compromise", "abc123"], {
|
|
1661
|
+
dbPath: tmp.dbPath,
|
|
1662
|
+
configDir: tmp.dir,
|
|
1663
|
+
}),
|
|
1664
|
+
);
|
|
1665
|
+
expect(code).toBe(1);
|
|
1666
|
+
expect(stderr).toContain("unexpected flag");
|
|
1667
|
+
expect(stderr).toContain("--reason");
|
|
1668
|
+
} finally {
|
|
1669
|
+
tmp.cleanup();
|
|
1670
|
+
}
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
test("revokes a fresh token and prints subject + scope", async () => {
|
|
1674
|
+
const tmp = makeTmp();
|
|
1675
|
+
try {
|
|
1676
|
+
const deps: AuthDeps = {
|
|
1677
|
+
dbPath: tmp.dbPath,
|
|
1678
|
+
configDir: tmp.dir,
|
|
1679
|
+
isInteractive: () => false,
|
|
1680
|
+
};
|
|
1681
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1682
|
+
const jti = await mintAJti(deps);
|
|
1683
|
+
const { code, stdout, stderr } = await captureOutput(() => auth(["revoke-token", jti], deps));
|
|
1684
|
+
expect(code).toBe(0);
|
|
1685
|
+
expect(stderr).toBe("");
|
|
1686
|
+
expect(stdout).toContain(`revoked: jti=${jti}`);
|
|
1687
|
+
expect(stdout).toContain("identity=");
|
|
1688
|
+
expect(stdout).toContain("scope=scribe:transcribe");
|
|
1689
|
+
|
|
1690
|
+
// Registry row really has revoked_at set now.
|
|
1691
|
+
const db = openHubDb(tmp.dbPath);
|
|
1692
|
+
try {
|
|
1693
|
+
const { findTokenRowByJti } = await import("../jwt-sign.ts");
|
|
1694
|
+
const row = findTokenRowByJti(db, jti);
|
|
1695
|
+
expect(row?.revokedAt).not.toBeNull();
|
|
1696
|
+
} finally {
|
|
1697
|
+
db.close();
|
|
1698
|
+
}
|
|
1699
|
+
} finally {
|
|
1700
|
+
tmp.cleanup();
|
|
1701
|
+
}
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
test("re-revoke is idempotent: exit 0, prints existing revoked_at", async () => {
|
|
1705
|
+
const tmp = makeTmp();
|
|
1706
|
+
try {
|
|
1707
|
+
const deps: AuthDeps = {
|
|
1708
|
+
dbPath: tmp.dbPath,
|
|
1709
|
+
configDir: tmp.dir,
|
|
1710
|
+
isInteractive: () => false,
|
|
1711
|
+
};
|
|
1712
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1713
|
+
const jti = await mintAJti(deps);
|
|
1714
|
+
const first = await captureOutput(() => auth(["revoke-token", jti], deps));
|
|
1715
|
+
expect(first.code).toBe(0);
|
|
1716
|
+
|
|
1717
|
+
// Capture the timestamp from the first revoke for cross-check.
|
|
1718
|
+
const db = openHubDb(tmp.dbPath);
|
|
1719
|
+
let firstRevokedAt: string | null;
|
|
1720
|
+
try {
|
|
1721
|
+
const { findTokenRowByJti } = await import("../jwt-sign.ts");
|
|
1722
|
+
firstRevokedAt = findTokenRowByJti(db, jti)?.revokedAt ?? null;
|
|
1723
|
+
} finally {
|
|
1724
|
+
db.close();
|
|
1725
|
+
}
|
|
1726
|
+
expect(firstRevokedAt).not.toBeNull();
|
|
1727
|
+
|
|
1728
|
+
const second = await captureOutput(() => auth(["revoke-token", jti], deps));
|
|
1729
|
+
expect(second.code).toBe(0);
|
|
1730
|
+
expect(second.stdout).toContain("already revoked at");
|
|
1731
|
+
expect(second.stdout).toContain(firstRevokedAt!);
|
|
1732
|
+
} finally {
|
|
1733
|
+
tmp.cleanup();
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
test("unknown jti exits 1 with not-found error", async () => {
|
|
1738
|
+
const tmp = makeTmp();
|
|
1739
|
+
try {
|
|
1740
|
+
const deps: AuthDeps = {
|
|
1741
|
+
dbPath: tmp.dbPath,
|
|
1742
|
+
configDir: tmp.dir,
|
|
1743
|
+
isInteractive: () => false,
|
|
1744
|
+
};
|
|
1745
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1746
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1747
|
+
auth(["revoke-token", "this-jti-does-not-exist"], deps),
|
|
1748
|
+
);
|
|
1749
|
+
expect(code).toBe(1);
|
|
1750
|
+
expect(stderr).toContain("no token with jti this-jti-does-not-exist found in registry");
|
|
1751
|
+
} finally {
|
|
1752
|
+
tmp.cleanup();
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
test("operator token without parachute:host:auth is rejected", async () => {
|
|
1757
|
+
const tmp = makeTmp();
|
|
1758
|
+
try {
|
|
1759
|
+
const deps: AuthDeps = {
|
|
1760
|
+
dbPath: tmp.dbPath,
|
|
1761
|
+
configDir: tmp.dir,
|
|
1762
|
+
isInteractive: () => false,
|
|
1763
|
+
};
|
|
1764
|
+
await captureOutput(() => auth(["set-password", "--password", "pw"], deps));
|
|
1765
|
+
// Replace operator.token with a narrow JWT that lacks parachute:host:auth.
|
|
1766
|
+
const db = openHubDb(tmp.dbPath);
|
|
1767
|
+
let narrow: string;
|
|
1768
|
+
try {
|
|
1769
|
+
const owner = listUsers(db)[0]!;
|
|
1770
|
+
const signed = await signAccessToken(db, {
|
|
1771
|
+
sub: owner.id,
|
|
1772
|
+
scopes: ["scribe:transcribe"],
|
|
1773
|
+
audience: "scribe",
|
|
1774
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
1775
|
+
issuer: "http://127.0.0.1:1939",
|
|
1776
|
+
ttlSeconds: 3600,
|
|
1777
|
+
});
|
|
1778
|
+
narrow = signed.token;
|
|
1779
|
+
} finally {
|
|
1780
|
+
db.close();
|
|
1781
|
+
}
|
|
1782
|
+
await writeOperatorTokenFile(narrow, tmp.dir);
|
|
1783
|
+
|
|
1784
|
+
const { code, stderr } = await captureOutput(() => auth(["revoke-token", "any-jti"], deps));
|
|
1785
|
+
expect(code).toBe(1);
|
|
1786
|
+
expect(stderr).toContain("lacks parachute:host:auth scope");
|
|
1787
|
+
expect(stderr).toContain("rotate-operator");
|
|
1788
|
+
} finally {
|
|
1789
|
+
tmp.cleanup();
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
test("missing operator.token is an actionable error", async () => {
|
|
1794
|
+
const tmp = makeTmp();
|
|
1795
|
+
try {
|
|
1796
|
+
const { code, stderr } = await captureOutput(() =>
|
|
1797
|
+
auth(["revoke-token", "any-jti"], { dbPath: tmp.dbPath, configDir: tmp.dir }),
|
|
1798
|
+
);
|
|
1799
|
+
expect(code).toBe(1);
|
|
1800
|
+
expect(stderr).toContain("operator.token");
|
|
1801
|
+
expect(stderr).toContain("rotate-operator");
|
|
1802
|
+
} finally {
|
|
1803
|
+
tmp.cleanup();
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
test("authHelp lists revoke-token alongside mint-token", () => {
|
|
1808
|
+
expect(authHelp()).toContain("revoke-token");
|
|
1809
|
+
expect(authHelp()).toContain("revoke-token <jti>");
|
|
1810
|
+
});
|
|
1147
1811
|
});
|