@openparachute/hub 0.5.0 → 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/package.json +1 -1
- package/src/__tests__/auth.test.ts +352 -1
- package/src/__tests__/lifecycle.test.ts +101 -4
- package/src/commands/auth.ts +200 -1
- package/src/commands/lifecycle.ts +39 -13
- package/src/jwt-audience.ts +40 -0
- package/src/oauth-handlers.ts +1 -32
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -517,9 +517,13 @@ describe("parachute start", () => {
|
|
|
517
517
|
}
|
|
518
518
|
});
|
|
519
519
|
|
|
520
|
-
test("third-party
|
|
521
|
-
// A row whose name isn't
|
|
522
|
-
//
|
|
520
|
+
test("start: installDir-less third-party row surfaces an actionable error", async () => {
|
|
521
|
+
// A services.json row whose name isn't first-party AND has no installDir
|
|
522
|
+
// can't yield a startCmd. Pre-fix this hit the generic "unknown service"
|
|
523
|
+
// path (misleading — the row exists, just with stale shape). Post-fix
|
|
524
|
+
// resolveTargets returns the entry with spec=undefined and start prints
|
|
525
|
+
// an actionable message that points at the real fix (re-install or
|
|
526
|
+
// upgrade-the-module).
|
|
523
527
|
const h = makeHarness();
|
|
524
528
|
try {
|
|
525
529
|
upsertService(
|
|
@@ -539,7 +543,30 @@ describe("parachute start", () => {
|
|
|
539
543
|
log: (l) => lines.push(l),
|
|
540
544
|
});
|
|
541
545
|
expect(code).toBe(1);
|
|
542
|
-
|
|
546
|
+
const out = lines.join("\n");
|
|
547
|
+
expect(out).toMatch(/services\.json entry has no installDir/);
|
|
548
|
+
expect(out).toMatch(/parachute install <path-to-mystery>/);
|
|
549
|
+
expect(out).not.toMatch(/unknown service/);
|
|
550
|
+
} finally {
|
|
551
|
+
h.cleanup();
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("start: name absent from services.json still errors as unknown service", async () => {
|
|
556
|
+
// The genuinely-unknown path: no first-party fallback, no row in
|
|
557
|
+
// services.json. Distinguish from the above (row exists but lacks
|
|
558
|
+
// installDir) so the error message is right-shaped for each.
|
|
559
|
+
const h = makeHarness();
|
|
560
|
+
try {
|
|
561
|
+
seedVault(h.manifestPath);
|
|
562
|
+
const lines: string[] = [];
|
|
563
|
+
const code = await start("ghost", {
|
|
564
|
+
configDir: h.configDir,
|
|
565
|
+
manifestPath: h.manifestPath,
|
|
566
|
+
log: (l) => lines.push(l),
|
|
567
|
+
});
|
|
568
|
+
expect(code).toBe(1);
|
|
569
|
+
expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
|
|
543
570
|
} finally {
|
|
544
571
|
h.cleanup();
|
|
545
572
|
}
|
|
@@ -721,6 +748,45 @@ describe("parachute stop", () => {
|
|
|
721
748
|
h.cleanup();
|
|
722
749
|
}
|
|
723
750
|
});
|
|
751
|
+
|
|
752
|
+
test("third-party row without installDir: stops via pidfile", async () => {
|
|
753
|
+
// Graceful-degradation path: an installed-but-stale third-party row
|
|
754
|
+
// (no installDir field — pre-installDir-contract self-registration)
|
|
755
|
+
// should still be stoppable. stop only needs the short name to find
|
|
756
|
+
// the pidfile; spec resolution isn't on the critical path for stop.
|
|
757
|
+
const h = makeHarness();
|
|
758
|
+
try {
|
|
759
|
+
upsertService(
|
|
760
|
+
{
|
|
761
|
+
name: "mystery",
|
|
762
|
+
port: 1944,
|
|
763
|
+
paths: ["/mystery"],
|
|
764
|
+
health: "/mystery/health",
|
|
765
|
+
version: "0.0.1",
|
|
766
|
+
},
|
|
767
|
+
h.manifestPath,
|
|
768
|
+
);
|
|
769
|
+
writePid("mystery", 4242, h.configDir);
|
|
770
|
+
const killed: Array<[number, string | number]> = [];
|
|
771
|
+
let aliveCall = 0;
|
|
772
|
+
const code = await stop("mystery", {
|
|
773
|
+
configDir: h.configDir,
|
|
774
|
+
manifestPath: h.manifestPath,
|
|
775
|
+
kill: (pid, sig) => killed.push([pid, sig]),
|
|
776
|
+
alive: () => {
|
|
777
|
+
aliveCall++;
|
|
778
|
+
return aliveCall === 1;
|
|
779
|
+
},
|
|
780
|
+
sleep: async () => {},
|
|
781
|
+
log: () => {},
|
|
782
|
+
});
|
|
783
|
+
expect(code).toBe(0);
|
|
784
|
+
expect(killed).toEqual([[4242, "SIGTERM"]]);
|
|
785
|
+
expect(readPid("mystery", h.configDir)).toBeUndefined();
|
|
786
|
+
} finally {
|
|
787
|
+
h.cleanup();
|
|
788
|
+
}
|
|
789
|
+
});
|
|
724
790
|
});
|
|
725
791
|
|
|
726
792
|
describe("parachute restart", () => {
|
|
@@ -822,6 +888,37 @@ describe("parachute logs", () => {
|
|
|
822
888
|
h.cleanup();
|
|
823
889
|
}
|
|
824
890
|
});
|
|
891
|
+
|
|
892
|
+
test("third-party row without installDir: tails by short name", async () => {
|
|
893
|
+
// Graceful-degradation path: log file is keyed by short name, written by
|
|
894
|
+
// start. installDir is irrelevant for tailing — the entry just needs to
|
|
895
|
+
// exist in services.json.
|
|
896
|
+
const h = makeHarness();
|
|
897
|
+
try {
|
|
898
|
+
upsertService(
|
|
899
|
+
{
|
|
900
|
+
name: "mystery",
|
|
901
|
+
port: 1944,
|
|
902
|
+
paths: ["/mystery"],
|
|
903
|
+
health: "/mystery/health",
|
|
904
|
+
version: "0.0.1",
|
|
905
|
+
},
|
|
906
|
+
h.manifestPath,
|
|
907
|
+
);
|
|
908
|
+
const p = ensureLogPath("mystery", h.configDir);
|
|
909
|
+
writeFileSync(p, "mystery line 1\nmystery line 2\n");
|
|
910
|
+
const lines: string[] = [];
|
|
911
|
+
const code = await logs("mystery", {
|
|
912
|
+
configDir: h.configDir,
|
|
913
|
+
manifestPath: h.manifestPath,
|
|
914
|
+
log: (l) => lines.push(l),
|
|
915
|
+
});
|
|
916
|
+
expect(code).toBe(0);
|
|
917
|
+
expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
|
|
918
|
+
} finally {
|
|
919
|
+
h.cleanup();
|
|
920
|
+
}
|
|
921
|
+
});
|
|
825
922
|
});
|
|
826
923
|
|
|
827
924
|
describe("process-group lifecycle (hub#88)", () => {
|
package/src/commands/auth.ts
CHANGED
|
@@ -25,7 +25,13 @@ import { listGrantsForUser, revokeGrant } from "../grants.ts";
|
|
|
25
25
|
import { HUB_DEFAULT_PORT, readHubPort } from "../hub-control.ts";
|
|
26
26
|
import { openHubDb } from "../hub-db.ts";
|
|
27
27
|
import { deriveHubOrigin } from "../hub-origin.ts";
|
|
28
|
-
import {
|
|
28
|
+
import { inferAudience } from "../jwt-audience.ts";
|
|
29
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
30
|
+
import {
|
|
31
|
+
OPERATOR_TOKEN_CLIENT_ID,
|
|
32
|
+
issueOperatorToken,
|
|
33
|
+
readOperatorTokenFile,
|
|
34
|
+
} from "../operator-token.ts";
|
|
29
35
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
30
36
|
import {
|
|
31
37
|
SingleUserModeError,
|
|
@@ -54,6 +60,7 @@ const HUB_LOCAL_SUBCOMMANDS = new Set([
|
|
|
54
60
|
"set-password",
|
|
55
61
|
"list-users",
|
|
56
62
|
"rotate-operator",
|
|
63
|
+
"mint-token",
|
|
57
64
|
"pending-clients",
|
|
58
65
|
"approve-client",
|
|
59
66
|
"list-grants",
|
|
@@ -73,6 +80,9 @@ Usage:
|
|
|
73
80
|
parachute auth 2fa backup-codes Regenerate backup codes
|
|
74
81
|
parachute auth rotate-key Rotate the hub's JWT signing key
|
|
75
82
|
parachute auth rotate-operator Mint a fresh ~/.parachute/operator.token
|
|
83
|
+
parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]
|
|
84
|
+
Mint a scope-narrow JWT against the
|
|
85
|
+
operator's identity (stdout = JWT)
|
|
76
86
|
parachute auth pending-clients List OAuth clients awaiting approval
|
|
77
87
|
parachute auth approve-client <id> Approve a pending OAuth client
|
|
78
88
|
parachute auth list-grants [--username <name>]
|
|
@@ -104,6 +114,15 @@ rotate-operator mints a fresh long-lived operator token at
|
|
|
104
114
|
as their bearer when calling on-box services. set-password also writes
|
|
105
115
|
the file on first-run / password reset.
|
|
106
116
|
|
|
117
|
+
mint-token issues a single scope-narrow JWT against the operator's
|
|
118
|
+
identity, signed with the same key as OAuth-issued tokens. Pipeable:
|
|
119
|
+
\`parachute auth mint-token --scope scribe:transcribe | pbcopy\`. The
|
|
120
|
+
audience defaults via the same inference rule the OAuth flow uses
|
|
121
|
+
(named \`vault:<name>:<verb>\` → \`vault.<name>\`, otherwise the first
|
|
122
|
+
colon-prefixed scope's namespace, fallback \`hub\`). TTL defaults to 90d,
|
|
123
|
+
caps at 365d. Requires a valid ~/.parachute/operator.token (run
|
|
124
|
+
\`parachute auth set-password\` or \`rotate-operator\` first).
|
|
125
|
+
|
|
107
126
|
pending-clients + approve-client gate /oauth/register against operator
|
|
108
127
|
approval (closes #74). Self-served DCR registrations land as 'pending'
|
|
109
128
|
and cannot OAuth until you run \`parachute auth approve-client <id>\`.
|
|
@@ -571,6 +590,177 @@ function runRevokeGrant(args: readonly string[], deps: AuthDeps): number {
|
|
|
571
590
|
}
|
|
572
591
|
}
|
|
573
592
|
|
|
593
|
+
interface MintTokenFlags {
|
|
594
|
+
scope?: string;
|
|
595
|
+
aud?: string;
|
|
596
|
+
ttl?: string;
|
|
597
|
+
sub?: string;
|
|
598
|
+
error?: string;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function parseMintTokenFlags(args: readonly string[]): MintTokenFlags {
|
|
602
|
+
let scope: string | undefined;
|
|
603
|
+
let aud: string | undefined;
|
|
604
|
+
let ttl: string | undefined;
|
|
605
|
+
let sub: string | undefined;
|
|
606
|
+
for (let i = 0; i < args.length; i++) {
|
|
607
|
+
const a = args[i];
|
|
608
|
+
if (a === "--scope") {
|
|
609
|
+
const v = args[++i];
|
|
610
|
+
if (!v) return { error: "--scope requires a value" };
|
|
611
|
+
scope = v;
|
|
612
|
+
} else if (a?.startsWith("--scope=")) {
|
|
613
|
+
scope = a.slice("--scope=".length);
|
|
614
|
+
if (!scope) return { error: "--scope requires a value" };
|
|
615
|
+
} else if (a === "--aud") {
|
|
616
|
+
const v = args[++i];
|
|
617
|
+
if (!v) return { error: "--aud requires a value" };
|
|
618
|
+
aud = v;
|
|
619
|
+
} else if (a?.startsWith("--aud=")) {
|
|
620
|
+
aud = a.slice("--aud=".length);
|
|
621
|
+
if (!aud) return { error: "--aud requires a value" };
|
|
622
|
+
} else if (a === "--ttl") {
|
|
623
|
+
const v = args[++i];
|
|
624
|
+
if (!v) return { error: "--ttl requires a value" };
|
|
625
|
+
ttl = v;
|
|
626
|
+
} else if (a?.startsWith("--ttl=")) {
|
|
627
|
+
ttl = a.slice("--ttl=".length);
|
|
628
|
+
if (!ttl) return { error: "--ttl requires a value" };
|
|
629
|
+
} else if (a === "--sub") {
|
|
630
|
+
const v = args[++i];
|
|
631
|
+
if (!v) return { error: "--sub requires a value" };
|
|
632
|
+
sub = v;
|
|
633
|
+
} else if (a?.startsWith("--sub=")) {
|
|
634
|
+
sub = a.slice("--sub=".length);
|
|
635
|
+
if (!sub) return { error: "--sub requires a value" };
|
|
636
|
+
} else {
|
|
637
|
+
return { error: `unknown flag "${a}"` };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return { scope, aud, ttl, sub };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const MINT_TOKEN_TTL_DEFAULT_SECONDS = 90 * 24 * 60 * 60;
|
|
644
|
+
const MINT_TOKEN_TTL_MAX_SECONDS = 365 * 24 * 60 * 60;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Parse a Go-ish duration string: integer + one of d/h/m/s. Caps at 365d.
|
|
648
|
+
* `90d` → 7776000. We don't honor Go's stdlib `time.ParseDuration` exactly
|
|
649
|
+
* (no `d` there), so this is a small custom parser to keep the operator
|
|
650
|
+
* surface obvious.
|
|
651
|
+
*/
|
|
652
|
+
function parseTtl(input: string): { seconds: number } | { error: string } {
|
|
653
|
+
const m = /^(\d+)(d|h|m|s)$/.exec(input);
|
|
654
|
+
if (!m) return { error: `invalid --ttl "${input}" — expected e.g. 90d, 24h, 30m, 60s` };
|
|
655
|
+
const n = Number.parseInt(m[1]!, 10);
|
|
656
|
+
if (!Number.isFinite(n) || n <= 0) return { error: `invalid --ttl "${input}" — must be > 0` };
|
|
657
|
+
const unit = m[2]!;
|
|
658
|
+
const mult = unit === "d" ? 86400 : unit === "h" ? 3600 : unit === "m" ? 60 : 1;
|
|
659
|
+
const seconds = n * mult;
|
|
660
|
+
if (seconds > MINT_TOKEN_TTL_MAX_SECONDS) {
|
|
661
|
+
return { error: `--ttl "${input}" exceeds 365d cap` };
|
|
662
|
+
}
|
|
663
|
+
return { seconds };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function runMintToken(args: readonly string[], deps: AuthDeps): Promise<number> {
|
|
667
|
+
const flags = parseMintTokenFlags(args);
|
|
668
|
+
if (flags.error) {
|
|
669
|
+
console.error(`parachute auth mint-token: ${flags.error}`);
|
|
670
|
+
return 1;
|
|
671
|
+
}
|
|
672
|
+
if (!flags.scope) {
|
|
673
|
+
console.error("parachute auth mint-token: --scope is required");
|
|
674
|
+
console.error(
|
|
675
|
+
"usage: parachute auth mint-token --scope <scope> [--aud <aud>] [--ttl <duration>] [--sub <sub>]",
|
|
676
|
+
);
|
|
677
|
+
return 1;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const scopes = flags.scope.split(/\s+/).filter((s) => s.length > 0);
|
|
681
|
+
if (scopes.length === 0) {
|
|
682
|
+
console.error("parachute auth mint-token: --scope must contain at least one scope");
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
let ttlSeconds = MINT_TOKEN_TTL_DEFAULT_SECONDS;
|
|
687
|
+
if (flags.ttl) {
|
|
688
|
+
const parsed = parseTtl(flags.ttl);
|
|
689
|
+
if ("error" in parsed) {
|
|
690
|
+
console.error(`parachute auth mint-token: ${parsed.error}`);
|
|
691
|
+
return 1;
|
|
692
|
+
}
|
|
693
|
+
ttlSeconds = parsed.seconds;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const configDir = deps.configDir ?? CONFIG_DIR;
|
|
697
|
+
const operatorToken = await readOperatorTokenFile(configDir);
|
|
698
|
+
if (!operatorToken) {
|
|
699
|
+
console.error(
|
|
700
|
+
"parachute auth mint-token: no operator token found at ~/.parachute/operator.token",
|
|
701
|
+
);
|
|
702
|
+
console.error(
|
|
703
|
+
"run `parachute auth set-password` (first run) or `parachute auth rotate-operator` to mint one",
|
|
704
|
+
);
|
|
705
|
+
return 1;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const issuer = resolveHubIssuer(deps.hubOrigin, configDir);
|
|
709
|
+
|
|
710
|
+
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
711
|
+
try {
|
|
712
|
+
let operatorSub: string;
|
|
713
|
+
try {
|
|
714
|
+
const validated = await validateAccessToken(db, operatorToken, issuer);
|
|
715
|
+
const sub = validated.payload.sub;
|
|
716
|
+
if (typeof sub !== "string" || sub.length === 0) {
|
|
717
|
+
console.error("parachute auth mint-token: operator token has no sub claim");
|
|
718
|
+
return 1;
|
|
719
|
+
}
|
|
720
|
+
// Scope gate: a valid signature + non-expired JWT at this path is not
|
|
721
|
+
// sufficient — the token must carry operator-equivalent scope. Without
|
|
722
|
+
// this, a narrowly-scoped JWT stashed at ~/.parachute/operator.token
|
|
723
|
+
// would be treated as operator-bearer and mint arbitrary tokens
|
|
724
|
+
// (privilege escalation: narrow → arbitrary). Only set-password and
|
|
725
|
+
// rotate-operator legitimately write to this path; both seed the full
|
|
726
|
+
// OPERATOR_TOKEN_SCOPES set, so hub:admin is the right gate.
|
|
727
|
+
const tokenScope =
|
|
728
|
+
typeof validated.payload.scope === "string"
|
|
729
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
730
|
+
: [];
|
|
731
|
+
if (!tokenScope.includes("hub:admin")) {
|
|
732
|
+
console.error("parachute auth mint-token: operator token lacks hub:admin scope");
|
|
733
|
+
console.error("run `parachute auth rotate-operator` to mint a fresh one");
|
|
734
|
+
return 1;
|
|
735
|
+
}
|
|
736
|
+
operatorSub = sub;
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
739
|
+
console.error(`parachute auth mint-token: operator token invalid — ${msg}`);
|
|
740
|
+
console.error(
|
|
741
|
+
"run `parachute auth rotate-operator` to mint a fresh one, or check that the hub origin matches",
|
|
742
|
+
);
|
|
743
|
+
return 1;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const audience = flags.aud ?? inferAudience(scopes);
|
|
747
|
+
const sub = flags.sub ?? operatorSub;
|
|
748
|
+
|
|
749
|
+
const minted = await signAccessToken(db, {
|
|
750
|
+
sub,
|
|
751
|
+
scopes,
|
|
752
|
+
audience,
|
|
753
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
754
|
+
issuer,
|
|
755
|
+
ttlSeconds,
|
|
756
|
+
});
|
|
757
|
+
console.log(minted.token);
|
|
758
|
+
return 0;
|
|
759
|
+
} finally {
|
|
760
|
+
db.close();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
574
764
|
function runListUsers(deps: AuthDeps): number {
|
|
575
765
|
const db = deps.dbPath ? openHubDb(deps.dbPath) : openHubDb();
|
|
576
766
|
try {
|
|
@@ -641,6 +831,15 @@ export async function auth(args: readonly string[], deps: AuthDeps | Runner = {}
|
|
|
641
831
|
return 1;
|
|
642
832
|
}
|
|
643
833
|
}
|
|
834
|
+
if (sub === "mint-token") {
|
|
835
|
+
try {
|
|
836
|
+
return await runMintToken(args.slice(1), normalized);
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
839
|
+
console.error(`parachute auth mint-token: ${msg}`);
|
|
840
|
+
return 1;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
644
843
|
if (sub === "pending-clients") {
|
|
645
844
|
try {
|
|
646
845
|
return runPendingClients(normalized);
|
|
@@ -214,8 +214,10 @@ interface ResolvedTarget {
|
|
|
214
214
|
* Lifecycle spec resolved at request time. First-party comes from
|
|
215
215
|
* `getSpec(short)`; third-party comes from
|
|
216
216
|
* `getSpecFromInstallDir(entry.installDir, ...)`. May be undefined when
|
|
217
|
-
* a row has neither —
|
|
218
|
-
*
|
|
217
|
+
* a row has neither — `start` prints the actionable "no installDir"
|
|
218
|
+
* re-install message for an installDir-less third-party row, or
|
|
219
|
+
* "lifecycle not yet supported" otherwise; `stop`/`logs` keep working
|
|
220
|
+
* via pidfile/logfile semantics keyed by `short`.
|
|
219
221
|
*/
|
|
220
222
|
spec: ServiceSpec | undefined;
|
|
221
223
|
}
|
|
@@ -248,6 +250,13 @@ async function specForEntry(
|
|
|
248
250
|
* `module.json` (which is what install copied to `entry.name` for
|
|
249
251
|
* third-party). First-party are addressed by their short name (vault,
|
|
250
252
|
* notes, …) and matched via `shortNameForManifest`.
|
|
253
|
+
*
|
|
254
|
+
* Named-path detail: a third-party row whose name matches but lacks
|
|
255
|
+
* `installDir` resolves to the entry with `spec: undefined` (rather than
|
|
256
|
+
* an "unknown service" error). `stop`/`logs` handle the spec-less case
|
|
257
|
+
* via pidfile/logfile semantics; `start` surfaces an actionable
|
|
258
|
+
* re-install hint downstream. The genuinely-unknown path (no first-party
|
|
259
|
+
* fallback AND no row in services.json) still errors as `unknown service`.
|
|
251
260
|
*/
|
|
252
261
|
async function resolveTargets(
|
|
253
262
|
svc: string | undefined,
|
|
@@ -268,13 +277,19 @@ async function resolveTargets(
|
|
|
268
277
|
}
|
|
269
278
|
return { targets: [{ short: svc, entry, spec: firstPartySpec }] };
|
|
270
279
|
}
|
|
271
|
-
// Third-party: match a services.json row by name.
|
|
272
|
-
//
|
|
280
|
+
// Third-party: match a services.json row by name. Rows with `installDir`
|
|
281
|
+
// resolve a full spec from the on-disk module.json. Rows without it are
|
|
282
|
+
// still managed (stop/logs use pidfile/logfile semantics keyed by short
|
|
283
|
+
// name), but with `spec: undefined` — `start` will surface an
|
|
284
|
+
// installDir-specific error downstream rather than reject up front.
|
|
273
285
|
const entry = manifest.services.find((s) => s.name === svc);
|
|
274
|
-
if (entry
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
286
|
+
if (entry) {
|
|
287
|
+
if (entry.installDir) {
|
|
288
|
+
const { spec, error } = await specForEntry(svc, entry);
|
|
289
|
+
if (error) return { error: `${svc}: invalid module.json — ${error}` };
|
|
290
|
+
return { targets: [{ short: svc, entry, spec }] };
|
|
291
|
+
}
|
|
292
|
+
return { targets: [{ short: svc, entry, spec: undefined }] };
|
|
278
293
|
}
|
|
279
294
|
return {
|
|
280
295
|
error: `unknown service "${svc}". known: ${knownServices().join(", ")}`,
|
|
@@ -323,7 +338,17 @@ export async function start(svc: string | undefined, opts: LifecycleOpts = {}):
|
|
|
323
338
|
|
|
324
339
|
const cmd = spec?.startCmd?.(entry);
|
|
325
340
|
if (!cmd || cmd.length === 0) {
|
|
326
|
-
|
|
341
|
+
// Distinguish the missing-installDir case from "spec resolved but has
|
|
342
|
+
// no startCmd" — the former is fixable by re-registering the module,
|
|
343
|
+
// the latter is a hub-level limitation. Third-party rows hit the first
|
|
344
|
+
// branch when their self-registration predates the installDir contract.
|
|
345
|
+
if (!getSpec(short) && !entry.installDir) {
|
|
346
|
+
r.log(
|
|
347
|
+
`${short}: services.json entry has no installDir, so the start command can't be resolved. Re-run \`parachute install <path-to-${short}>\` to refresh its registration, or upgrade the module to a version that self-registers with installDir.`,
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
r.log(`${short}: lifecycle not yet supported for this service.`);
|
|
351
|
+
}
|
|
327
352
|
failures++;
|
|
328
353
|
continue;
|
|
329
354
|
}
|
|
@@ -487,13 +512,14 @@ export async function logs(svc: string, opts: LogsOpts = {}): Promise<number> {
|
|
|
487
512
|
|
|
488
513
|
// logs only needs a valid short name to find the log file. First-party
|
|
489
514
|
// wins via the spec lookup; third-party rows match by `entry.name`; the
|
|
490
|
-
// internal hub is a known short outside of services.json.
|
|
491
|
-
//
|
|
492
|
-
//
|
|
515
|
+
// internal hub is a known short outside of services.json. installDir is
|
|
516
|
+
// irrelevant here — the log file is keyed by short name and exists once
|
|
517
|
+
// the service has run, regardless of how it was registered. We just need
|
|
518
|
+
// to confirm the name maps to something the CLI manages.
|
|
493
519
|
const isFirstParty = getSpec(svc) !== undefined;
|
|
494
520
|
if (!isFirstParty && svc !== HUB_SVC) {
|
|
495
521
|
const entry = readManifest(manifestPath).services.find((s) => s.name === svc);
|
|
496
|
-
if (!entry
|
|
522
|
+
if (!entry) {
|
|
497
523
|
log(`unknown service "${svc}". known: ${[HUB_SVC, ...knownServices()].join(", ")}`);
|
|
498
524
|
return 1;
|
|
499
525
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audience derivation for hub-issued JWTs. Used by both:
|
|
3
|
+
* - `/oauth/token` (auth_code redemption + refresh rotation)
|
|
4
|
+
* - `parachute auth mint-token` (CLI shortcut for scope-narrow tokens)
|
|
5
|
+
*
|
|
6
|
+
* Per the vault-config-and-scopes design (Phase 1+2):
|
|
7
|
+
* - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
|
|
8
|
+
* binding; vault enforces this strict-equality against the URL-derived
|
|
9
|
+
* vault name).
|
|
10
|
+
* - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
|
|
11
|
+
* strict-check rejects unnamed `vault:*` audiences, so the consent
|
|
12
|
+
* picker rewrites those before this is reached).
|
|
13
|
+
* - Fallback: `hub` (no namespaced scope).
|
|
14
|
+
*
|
|
15
|
+
* Named vault scopes win over unnamed ones — an OAuth flow that mixes
|
|
16
|
+
* `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
|
|
17
|
+
* (the more sensitive resource), and tokens are issued per-flow anyway.
|
|
18
|
+
*
|
|
19
|
+
* Hoisted from `oauth-handlers.ts` so CLI mints and OAuth mints can't diverge
|
|
20
|
+
* on audience semantics — a divergence here means tokens minted via CLI fail
|
|
21
|
+
* audience strict-check at the resource server even though scopes match.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export const VAULT_VERBS = new Set(["read", "write", "admin"]);
|
|
25
|
+
|
|
26
|
+
export function inferAudience(scopes: readonly string[]): string {
|
|
27
|
+
for (const s of scopes) {
|
|
28
|
+
const parts = s.split(":");
|
|
29
|
+
const name = parts[1];
|
|
30
|
+
const verb = parts[2];
|
|
31
|
+
if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
|
|
32
|
+
return `vault.${name}`;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const s of scopes) {
|
|
36
|
+
const colon = s.indexOf(":");
|
|
37
|
+
if (colon > 0) return s.slice(0, colon);
|
|
38
|
+
}
|
|
39
|
+
return "hub";
|
|
40
|
+
}
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
} from "./clients.ts";
|
|
44
44
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
45
45
|
import { isCoveredByGrant, recordGrant } from "./grants.ts";
|
|
46
|
+
import { VAULT_VERBS, inferAudience } from "./jwt-audience.ts";
|
|
46
47
|
import {
|
|
47
48
|
ACCESS_TOKEN_TTL_SECONDS,
|
|
48
49
|
RefreshTokenInsertError,
|
|
@@ -69,8 +70,6 @@ import {
|
|
|
69
70
|
import { getUserByUsername, verifyPassword } from "./users.ts";
|
|
70
71
|
import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
|
|
71
72
|
|
|
72
|
-
const VAULT_VERBS = new Set(["read", "write", "admin"]);
|
|
73
|
-
|
|
74
73
|
/** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
|
|
75
74
|
function unnamedVaultVerbs(scopes: string[]): string[] {
|
|
76
75
|
const verbs: string[] = [];
|
|
@@ -1056,36 +1055,6 @@ function mapAuthCodeError(err: unknown): Response {
|
|
|
1056
1055
|
return jsonResponse({ error: "server_error", error_description: msg }, 500);
|
|
1057
1056
|
}
|
|
1058
1057
|
|
|
1059
|
-
/**
|
|
1060
|
-
* Picks the JWT `aud` claim based on the requested scopes. Per the
|
|
1061
|
-
* vault-config-and-scopes design (Phase 1+2):
|
|
1062
|
-
* - A named `vault:<name>:<verb>` → `vault.<name>` (RFC 8707-style resource
|
|
1063
|
-
* binding; vault enforces this strict-equality against the URL-derived
|
|
1064
|
-
* vault name).
|
|
1065
|
-
* - An unnamed `<service>:<verb>` → `<service>` (legacy shape; vault's
|
|
1066
|
-
* strict-check rejects unnamed `vault:*` audiences, so the consent
|
|
1067
|
-
* picker rewrites those before this is reached).
|
|
1068
|
-
*
|
|
1069
|
-
* Named vault scopes win over unnamed ones — an OAuth flow that mixes
|
|
1070
|
-
* `vault:work:read` + `scribe:transcribe` audiences is grounded on the vault
|
|
1071
|
-
* (the more sensitive resource), and tokens are issued per-flow anyway.
|
|
1072
|
-
*/
|
|
1073
|
-
function inferAudience(scopes: string[]): string {
|
|
1074
|
-
for (const s of scopes) {
|
|
1075
|
-
const parts = s.split(":");
|
|
1076
|
-
const name = parts[1];
|
|
1077
|
-
const verb = parts[2];
|
|
1078
|
-
if (parts.length === 3 && parts[0] === "vault" && name && verb && VAULT_VERBS.has(verb)) {
|
|
1079
|
-
return `vault.${name}`;
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
for (const s of scopes) {
|
|
1083
|
-
const colon = s.indexOf(":");
|
|
1084
|
-
if (colon > 0) return s.slice(0, colon);
|
|
1085
|
-
}
|
|
1086
|
-
return "hub";
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
1058
|
// --- /oauth/register -------------------------------------------------------
|
|
1090
1059
|
|
|
1091
1060
|
interface RegisterRequestBody {
|