@openparachute/hub 0.5.0 → 0.5.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -6,11 +6,13 @@ import { registerClient } from "../clients.ts";
6
6
  import { type AuthDeps, type Runner, auth, authHelp } from "../commands/auth.ts";
7
7
  import { findGrant, recordGrant } from "../grants.ts";
8
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
- import { validateAccessToken } from "../jwt-sign.ts";
9
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
10
10
  import {
11
11
  OPERATOR_TOKEN_AUDIENCE,
12
+ OPERATOR_TOKEN_CLIENT_ID,
12
13
  OPERATOR_TOKEN_SCOPES,
13
14
  readOperatorTokenFile,
15
+ writeOperatorTokenFile,
14
16
  } from "../operator-token.ts";
15
17
  import { createUser, listUsers, verifyPassword } from "../users.ts";
16
18
 
@@ -794,3 +796,352 @@ describe("parachute auth list-grants / revoke-grant", () => {
794
796
  }
795
797
  });
796
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
+ });
1147
+ });