@openparachute/hub 0.6.3-rc.2 → 0.6.3-rc.3
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__/api-modules-ops.test.ts +121 -0
- package/src/__tests__/api-modules.test.ts +67 -0
- package/src/__tests__/host-admin-token-validation.test.ts +218 -0
- package/src/__tests__/migrate.test.ts +16 -0
- package/src/__tests__/operator-token.test.ts +277 -0
- package/src/api-modules-ops.ts +28 -2
- package/src/api-modules.ts +25 -2
- package/src/host-admin-token-validation.ts +96 -0
- package/src/hub-server.ts +19 -3
- package/src/operator-token.ts +96 -5
- package/src/origin-check.ts +10 -0
package/package.json
CHANGED
|
@@ -70,6 +70,36 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
|
70
70
|
return signed.token;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Mint a host-admin bearer at a chosen `iss` (hub#516). The CLI presents the
|
|
75
|
+
* operator token on loopback; after `expose` its `iss` is the public origin
|
|
76
|
+
* while the loopback request resolves the loopback issuer.
|
|
77
|
+
*/
|
|
78
|
+
async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
|
|
79
|
+
const signed = await signAccessToken(h.db, {
|
|
80
|
+
sub: h.userId,
|
|
81
|
+
scopes,
|
|
82
|
+
audience: "operator",
|
|
83
|
+
clientId: "parachute-hub",
|
|
84
|
+
issuer: iss,
|
|
85
|
+
ttlSeconds: 3600,
|
|
86
|
+
});
|
|
87
|
+
recordTokenMint(h.db, {
|
|
88
|
+
jti: signed.jti,
|
|
89
|
+
createdVia: "operator_mint",
|
|
90
|
+
subject: "operator",
|
|
91
|
+
clientId: "parachute-hub",
|
|
92
|
+
scopes,
|
|
93
|
+
expiresAt: signed.expiresAt,
|
|
94
|
+
});
|
|
95
|
+
return signed.token;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The hub's public origin after `expose` — what the operator token's `iss` becomes. */
|
|
99
|
+
const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
|
|
100
|
+
/** A foreign origin the hub never answers on. */
|
|
101
|
+
const FOREIGN_ORIGIN = "https://evil.example.com";
|
|
102
|
+
|
|
73
103
|
function postReq(path: string, headers: Record<string, string>): Request {
|
|
74
104
|
return new Request(`http://localhost${path}`, { method: "POST", headers });
|
|
75
105
|
}
|
|
@@ -967,6 +997,97 @@ describe("POST /api/modules/:short/stop", () => {
|
|
|
967
997
|
});
|
|
968
998
|
});
|
|
969
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* hub#516 — the module-ops `authorize` accepts the operator token's `iss`
|
|
1002
|
+
* against the SET of origins the hub answers on (knownIssuers), not just the
|
|
1003
|
+
* single per-request issuer. Exercised through `handleStop` (the simplest sync
|
|
1004
|
+
* op that goes through `authorize`). The per-request `issuer` here is loopback
|
|
1005
|
+
* (mirroring a loopback CLI request); `knownIssuers` carries loopback + the
|
|
1006
|
+
* public expose-state origin.
|
|
1007
|
+
*/
|
|
1008
|
+
describe("operator-token iss validation against knownIssuers (hub#516)", () => {
|
|
1009
|
+
let h: Harness;
|
|
1010
|
+
beforeEach(async () => {
|
|
1011
|
+
h = await makeHarness();
|
|
1012
|
+
_resetOperationsRegistryForTests();
|
|
1013
|
+
});
|
|
1014
|
+
afterEach(() => h.cleanup());
|
|
1015
|
+
|
|
1016
|
+
// The live repro: operator token's iss = PUBLIC origin, loopback request
|
|
1017
|
+
// (per-request issuer = loopback), knownIssuers includes the public origin
|
|
1018
|
+
// → ACCEPTED. Was rejected (`unexpected "iss" claim value`) pre-fix.
|
|
1019
|
+
test("live repro: public-iss operator token on a loopback request → ACCEPTED", async () => {
|
|
1020
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1021
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
1022
|
+
const res = await handleStop(
|
|
1023
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1024
|
+
"vault",
|
|
1025
|
+
{
|
|
1026
|
+
db: h.db,
|
|
1027
|
+
issuer: ISSUER, // loopback per-request issuer
|
|
1028
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
1029
|
+
manifestPath: h.manifestPath,
|
|
1030
|
+
configDir: h.dir,
|
|
1031
|
+
supervisor,
|
|
1032
|
+
},
|
|
1033
|
+
);
|
|
1034
|
+
// Not a 401 — authorize passed. (stopped:false because nothing's running.)
|
|
1035
|
+
expect(res.status).toBe(200);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("loopback-iss operator token, loopback knownIssuers → accepted (unchanged)", async () => {
|
|
1039
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1040
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], ISSUER);
|
|
1041
|
+
const res = await handleStop(
|
|
1042
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1043
|
+
"vault",
|
|
1044
|
+
{
|
|
1045
|
+
db: h.db,
|
|
1046
|
+
issuer: ISSUER,
|
|
1047
|
+
knownIssuers: [ISSUER, "http://localhost:1939"],
|
|
1048
|
+
manifestPath: h.manifestPath,
|
|
1049
|
+
configDir: h.dir,
|
|
1050
|
+
supervisor,
|
|
1051
|
+
},
|
|
1052
|
+
);
|
|
1053
|
+
expect(res.status).toBe(200);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("FOREIGN-iss operator token → 401 (no widening to arbitrary issuers)", async () => {
|
|
1057
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1058
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], FOREIGN_ORIGIN);
|
|
1059
|
+
const res = await handleStop(
|
|
1060
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1061
|
+
"vault",
|
|
1062
|
+
{
|
|
1063
|
+
db: h.db,
|
|
1064
|
+
issuer: ISSUER,
|
|
1065
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
1066
|
+
manifestPath: h.manifestPath,
|
|
1067
|
+
configDir: h.dir,
|
|
1068
|
+
supervisor,
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
expect(res.status).toBe(401);
|
|
1072
|
+
const body = (await res.json()) as { error_description: string };
|
|
1073
|
+
expect(body.error_description).toMatch(/unexpected "iss" claim value/);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("knownIssuers absent → falls back to strict per-request issuer (back-compat)", async () => {
|
|
1077
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1078
|
+
// Public-iss token, loopback per-request issuer, NO knownIssuers wired →
|
|
1079
|
+
// the strict single-issuer fallback rejects (the pre-fix behavior the
|
|
1080
|
+
// non-HTTP install path relies on).
|
|
1081
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
1082
|
+
const res = await handleStop(
|
|
1083
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1084
|
+
"vault",
|
|
1085
|
+
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
|
|
1086
|
+
);
|
|
1087
|
+
expect(res.status).toBe(401);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
970
1091
|
describe("POST /api/modules/:short/restart", () => {
|
|
971
1092
|
let h: Harness;
|
|
972
1093
|
beforeEach(async () => {
|
|
@@ -63,6 +63,32 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
|
63
63
|
return signed.token;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** The hub's public origin after `expose` — what the operator token's `iss` becomes (hub#516). */
|
|
67
|
+
const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
|
|
68
|
+
/** A foreign origin the hub never answers on (hub#516). */
|
|
69
|
+
const FOREIGN_ORIGIN = "https://evil.example.com";
|
|
70
|
+
|
|
71
|
+
/** Mint a host-admin (operator-shaped) bearer at a chosen `iss` (hub#516). */
|
|
72
|
+
async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
|
|
73
|
+
const signed = await signAccessToken(h.db, {
|
|
74
|
+
sub: h.userId,
|
|
75
|
+
scopes,
|
|
76
|
+
audience: "operator",
|
|
77
|
+
clientId: "parachute-hub",
|
|
78
|
+
issuer: iss,
|
|
79
|
+
ttlSeconds: 3600,
|
|
80
|
+
});
|
|
81
|
+
recordTokenMint(h.db, {
|
|
82
|
+
jti: signed.jti,
|
|
83
|
+
createdVia: "operator_mint",
|
|
84
|
+
subject: "operator",
|
|
85
|
+
clientId: "parachute-hub",
|
|
86
|
+
scopes,
|
|
87
|
+
expiresAt: signed.expiresAt,
|
|
88
|
+
});
|
|
89
|
+
return signed.token;
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
function writeManifest(path: string, services: unknown[]): void {
|
|
67
93
|
writeFileSync(path, JSON.stringify({ services }));
|
|
68
94
|
}
|
|
@@ -147,6 +173,47 @@ describe("GET /api/modules", () => {
|
|
|
147
173
|
expect(body.error).toBe("insufficient_scope");
|
|
148
174
|
});
|
|
149
175
|
|
|
176
|
+
// hub#516: `parachute status` reads /api/modules on loopback presenting the
|
|
177
|
+
// operator token, whose `iss` is the PUBLIC origin after `expose`. The
|
|
178
|
+
// host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
|
|
179
|
+
// expose-state public ∪ env), not the single per-request loopback issuer.
|
|
180
|
+
test("live repro: public-iss operator token on a loopback request → 200 (hub#516)", async () => {
|
|
181
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
182
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
183
|
+
db: h.db,
|
|
184
|
+
issuer: ISSUER, // loopback per-request issuer
|
|
185
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
186
|
+
manifestPath: h.manifestPath,
|
|
187
|
+
fetchLatestVersion: async () => null,
|
|
188
|
+
});
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("FOREIGN-iss operator token → 401 (no widening) (hub#516)", async () => {
|
|
193
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], FOREIGN_ORIGIN);
|
|
194
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
195
|
+
db: h.db,
|
|
196
|
+
issuer: ISSUER,
|
|
197
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
198
|
+
manifestPath: h.manifestPath,
|
|
199
|
+
fetchLatestVersion: async () => null,
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(401);
|
|
202
|
+
const body = (await res.json()) as { error_description: string };
|
|
203
|
+
expect(body.error_description).toMatch(/unexpected "iss" claim value/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("knownIssuers absent → strict per-request issuer fallback rejects public-iss (hub#516)", async () => {
|
|
207
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
208
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
209
|
+
db: h.db,
|
|
210
|
+
issuer: ISSUER, // no knownIssuers → falls back to [issuer]
|
|
211
|
+
manifestPath: h.manifestPath,
|
|
212
|
+
fetchLatestVersion: async () => null,
|
|
213
|
+
});
|
|
214
|
+
expect(res.status).toBe(401);
|
|
215
|
+
});
|
|
216
|
+
|
|
150
217
|
test("200 + curated list on fresh container (empty services.json)", async () => {
|
|
151
218
|
// The v0.6 hot path: brand-new Render container, no services.json
|
|
152
219
|
// yet. UI must render "install vault / scribe" cards even though
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hub#516 — `validateHostAdminToken` accepts the operator / SPA host-admin
|
|
3
|
+
* token's `iss` against the SET of origins the hub answers on (loopback ∪
|
|
4
|
+
* expose-state public ∪ env/platform), not the single per-request issuer, so
|
|
5
|
+
* the loopback CLI works on an exposed box. OAuth-token validation
|
|
6
|
+
* (`validateAccessToken` with a pinned `expectedIssuer`) is NOT touched — see
|
|
7
|
+
* the strict-iss regression at the bottom of this file.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { validateHostAdminToken } from "../host-admin-token-validation.ts";
|
|
14
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
15
|
+
import { recordTokenMint, signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
16
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
18
|
+
|
|
19
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
20
|
+
const PUBLIC_TS = "https://parachute.taildf9ce2.ts.net";
|
|
21
|
+
const FOREIGN = "https://evil.example.com";
|
|
22
|
+
|
|
23
|
+
interface H {
|
|
24
|
+
db: ReturnType<typeof openHubDb>;
|
|
25
|
+
userId: string;
|
|
26
|
+
cleanup: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function makeH(): Promise<H> {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-host-admin-tok-"));
|
|
31
|
+
const db = openHubDb(hubDbPath(dir));
|
|
32
|
+
rotateSigningKey(db);
|
|
33
|
+
const user = await createUser(db, "owner", "pw");
|
|
34
|
+
return {
|
|
35
|
+
db,
|
|
36
|
+
userId: user.id,
|
|
37
|
+
cleanup: () => {
|
|
38
|
+
db.close();
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mint an operator-shaped token (aud: "operator", host:admin) at `iss`. */
|
|
45
|
+
async function mintOperatorAt(h: H, iss: string): Promise<string> {
|
|
46
|
+
const signed = await signAccessToken(h.db, {
|
|
47
|
+
sub: h.userId,
|
|
48
|
+
scopes: ["parachute:host:admin", "parachute:host:auth"],
|
|
49
|
+
audience: "operator",
|
|
50
|
+
clientId: "parachute-hub",
|
|
51
|
+
issuer: iss,
|
|
52
|
+
ttlSeconds: 3600,
|
|
53
|
+
});
|
|
54
|
+
recordTokenMint(h.db, {
|
|
55
|
+
jti: signed.jti,
|
|
56
|
+
createdVia: "operator_mint",
|
|
57
|
+
subject: "operator",
|
|
58
|
+
clientId: "parachute-hub",
|
|
59
|
+
scopes: ["parachute:host:admin", "parachute:host:auth"],
|
|
60
|
+
expiresAt: signed.expiresAt,
|
|
61
|
+
});
|
|
62
|
+
return signed.token;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("validateHostAdminToken (hub#516)", () => {
|
|
66
|
+
// The live repro: operator token minted with the PUBLIC origin as `iss`,
|
|
67
|
+
// presented on a LOOPBACK request (known-origins set built from the loopback
|
|
68
|
+
// per-request issuer + the expose-state public origin) → ACCEPTED.
|
|
69
|
+
test("live repro: public-iss operator token accepted when public origin is in the known set", async () => {
|
|
70
|
+
const h = await makeH();
|
|
71
|
+
try {
|
|
72
|
+
const token = await mintOperatorAt(h, PUBLIC_TS);
|
|
73
|
+
const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
|
|
74
|
+
const { payload } = await validateHostAdminToken(h.db, token, knownIssuers);
|
|
75
|
+
expect(payload.iss).toBe(PUBLIC_TS);
|
|
76
|
+
expect(payload.sub).toBe(h.userId);
|
|
77
|
+
} finally {
|
|
78
|
+
h.cleanup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("loopback-iss operator token, loopback known set → accepted (unchanged)", async () => {
|
|
83
|
+
const h = await makeH();
|
|
84
|
+
try {
|
|
85
|
+
const token = await mintOperatorAt(h, LOOPBACK);
|
|
86
|
+
const { payload } = await validateHostAdminToken(h.db, token, [
|
|
87
|
+
LOOPBACK,
|
|
88
|
+
"http://localhost:1939",
|
|
89
|
+
]);
|
|
90
|
+
expect(payload.iss).toBe(LOOPBACK);
|
|
91
|
+
} finally {
|
|
92
|
+
h.cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("FOREIGN iss → REJECTED (no widening to arbitrary issuers)", async () => {
|
|
97
|
+
const h = await makeH();
|
|
98
|
+
try {
|
|
99
|
+
// A token the hub itself signed but whose iss is NOT one of its known
|
|
100
|
+
// origins. The signature verifies, but the belt-and-suspenders iss ∈
|
|
101
|
+
// known-origins check must still reject it.
|
|
102
|
+
const token = await mintOperatorAt(h, FOREIGN);
|
|
103
|
+
const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
|
|
104
|
+
await expect(validateHostAdminToken(h.db, token, knownIssuers)).rejects.toThrow(
|
|
105
|
+
/unexpected "iss" claim value/,
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
h.cleanup();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("empty known-origins set rejects every token (fails closed)", async () => {
|
|
113
|
+
const h = await makeH();
|
|
114
|
+
try {
|
|
115
|
+
const token = await mintOperatorAt(h, LOOPBACK);
|
|
116
|
+
await expect(validateHostAdminToken(h.db, token, [])).rejects.toThrow(
|
|
117
|
+
/unexpected "iss" claim value/,
|
|
118
|
+
);
|
|
119
|
+
} finally {
|
|
120
|
+
h.cleanup();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("expose-state absent (loopback-only set): public-iss token rejected; loopback-iss accepted", async () => {
|
|
125
|
+
const h = await makeH();
|
|
126
|
+
try {
|
|
127
|
+
// Before `expose`, the known set is loopback-only — a public-iss token
|
|
128
|
+
// shouldn't exist yet, and if presented, it's not in the set → reject.
|
|
129
|
+
const publicTok = await mintOperatorAt(h, PUBLIC_TS);
|
|
130
|
+
const loopbackOnly = [LOOPBACK, "http://localhost:1939"];
|
|
131
|
+
await expect(validateHostAdminToken(h.db, publicTok, loopbackOnly)).rejects.toThrow(
|
|
132
|
+
/unexpected "iss" claim value/,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const loopbackTok = await mintOperatorAt(h, LOOPBACK);
|
|
136
|
+
const { payload } = await validateHostAdminToken(h.db, loopbackTok, loopbackOnly);
|
|
137
|
+
expect(payload.iss).toBe(LOOPBACK);
|
|
138
|
+
} finally {
|
|
139
|
+
h.cleanup();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("signature still enforced: a token signed by an unknown key is rejected", async () => {
|
|
144
|
+
const h = await makeH();
|
|
145
|
+
try {
|
|
146
|
+
// Mint against a SECOND hub's key, then try to validate against the
|
|
147
|
+
// first hub's JWKS. The known-origins set includes the iss, but the
|
|
148
|
+
// signature check (step 1) must reject it before iss is even considered.
|
|
149
|
+
const other = await makeH();
|
|
150
|
+
try {
|
|
151
|
+
const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
|
|
152
|
+
await expect(validateHostAdminToken(h.db, foreignSigned, [PUBLIC_TS])).rejects.toThrow();
|
|
153
|
+
} finally {
|
|
154
|
+
other.cleanup();
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
h.cleanup();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Host-injection defense, made explicit. Simulate an attacker who got their
|
|
162
|
+
// own issuer into the known-issuers set via a forged Host header (the set is
|
|
163
|
+
// built from the per-request Host-derived issuer, so `iss ∈ knownIssuers`
|
|
164
|
+
// holds). They present a token signed by a DIFFERENT hub's key whose `iss`
|
|
165
|
+
// is exactly that injected origin. It is STILL REJECTED — the signature /
|
|
166
|
+
// JWKS check (step 1) fails before the `iss ∈ knownIssuers` check (step 2)
|
|
167
|
+
// is ever reached. This pins that known-issuers acceptance can never bypass
|
|
168
|
+
// the signature gate: membership in the set is necessary but not sufficient.
|
|
169
|
+
test("Host-injection: foreign-signed token with iss IN knownIssuers is STILL rejected (signature gate is first)", async () => {
|
|
170
|
+
const h = await makeH();
|
|
171
|
+
try {
|
|
172
|
+
const other = await makeH();
|
|
173
|
+
try {
|
|
174
|
+
// Token minted + signed by the OTHER hub at PUBLIC_TS.
|
|
175
|
+
const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
|
|
176
|
+
// The attacker's iss IS in our known set (e.g. injected via Host), yet
|
|
177
|
+
// validation against OUR JWKS must reject it on the signature check.
|
|
178
|
+
const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
|
|
179
|
+
expect(knownIssuers).toContain(PUBLIC_TS); // precondition: iss ∈ knownIssuers
|
|
180
|
+
await expect(validateHostAdminToken(h.db, foreignSigned, knownIssuers)).rejects.toThrow();
|
|
181
|
+
} finally {
|
|
182
|
+
other.cleanup();
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
h.cleanup();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Pin the invariant: OAuth/access-token validation stays STRICT per-request
|
|
190
|
+
// issuer. The relaxation lives only in validateHostAdminToken; the core
|
|
191
|
+
// primitive validateAccessToken(db, token, expectedIssuer) still rejects a
|
|
192
|
+
// mismatched iss. (Mirrors jwt-sign.test.ts's defense-in-depth test; kept
|
|
193
|
+
// here so the hub#516 guard travels with the relaxation.)
|
|
194
|
+
test("OAuth-token validation UNCHANGED: validateAccessToken pins iss strictly", async () => {
|
|
195
|
+
const h = await makeH();
|
|
196
|
+
try {
|
|
197
|
+
// A vault-aud OAuth-shaped token minted at the public origin.
|
|
198
|
+
const signed = await signAccessToken(h.db, {
|
|
199
|
+
sub: h.userId,
|
|
200
|
+
scopes: ["vault:read"],
|
|
201
|
+
audience: "vault.default",
|
|
202
|
+
clientId: "some-mcp-client",
|
|
203
|
+
issuer: PUBLIC_TS,
|
|
204
|
+
ttlSeconds: 3600,
|
|
205
|
+
});
|
|
206
|
+
// Strict per-request validation against a DIFFERENT (loopback) issuer
|
|
207
|
+
// still rejects — the relaxation must NOT leak to this path.
|
|
208
|
+
await expect(validateAccessToken(h.db, signed.token, LOOPBACK)).rejects.toThrow(
|
|
209
|
+
/unexpected "iss" claim value/,
|
|
210
|
+
);
|
|
211
|
+
// And it accepts when the issuer matches (sanity).
|
|
212
|
+
const { payload } = await validateAccessToken(h.db, signed.token, PUBLIC_TS);
|
|
213
|
+
expect(payload.aud).toBe("vault.default");
|
|
214
|
+
} finally {
|
|
215
|
+
h.cleanup();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -296,6 +296,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
296
296
|
throw new Error("prompt must not be called");
|
|
297
297
|
},
|
|
298
298
|
isTty: true,
|
|
299
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
299
300
|
});
|
|
300
301
|
expect(code).toBe(0);
|
|
301
302
|
expect(logs.join("\n")).toMatch(/nothing to archive/i);
|
|
@@ -325,6 +326,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
325
326
|
},
|
|
326
327
|
list: true,
|
|
327
328
|
isTty: true,
|
|
329
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
328
330
|
});
|
|
329
331
|
expect(code).toBe(0);
|
|
330
332
|
expect(logs.join("\n")).toMatch(/--list — no changes made/);
|
|
@@ -351,6 +353,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
351
353
|
},
|
|
352
354
|
dryRun: true,
|
|
353
355
|
isTty: true,
|
|
356
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
354
357
|
});
|
|
355
358
|
expect(code).toBe(0);
|
|
356
359
|
expect(logs.join("\n")).toMatch(/dry-run/);
|
|
@@ -383,6 +386,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
383
386
|
},
|
|
384
387
|
yes: true,
|
|
385
388
|
isTty: false,
|
|
389
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
386
390
|
});
|
|
387
391
|
expect(code).toBe(0);
|
|
388
392
|
const archive = join(h.configDir, ".archive-2026-04-19");
|
|
@@ -510,6 +514,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
510
514
|
throw new Error("prompt must not be called");
|
|
511
515
|
},
|
|
512
516
|
isTty: false,
|
|
517
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
513
518
|
});
|
|
514
519
|
expect(code).toBe(1);
|
|
515
520
|
expect(logs.join("\n")).toMatch(/refusing to sweep without a TTY/i);
|
|
@@ -533,6 +538,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
533
538
|
log: () => {},
|
|
534
539
|
prompt: async () => answers.shift() ?? "n",
|
|
535
540
|
isTty: true,
|
|
541
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
536
542
|
});
|
|
537
543
|
expect(code).toBe(1);
|
|
538
544
|
// Aborted before any rename — daily.db still there.
|
|
@@ -557,6 +563,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
557
563
|
log: () => {},
|
|
558
564
|
prompt: async () => answers.shift() ?? "y",
|
|
559
565
|
isTty: true,
|
|
566
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
560
567
|
});
|
|
561
568
|
expect(code).toBe(0);
|
|
562
569
|
const archive = join(h.configDir, ".archive-2026-04-19");
|
|
@@ -588,6 +595,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
588
595
|
},
|
|
589
596
|
yes: true,
|
|
590
597
|
isTty: false,
|
|
598
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
591
599
|
});
|
|
592
600
|
expect(code).toBe(0);
|
|
593
601
|
const archivedLink = join(h.configDir, ".archive-2026-04-19", "logs");
|
|
@@ -621,6 +629,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
621
629
|
},
|
|
622
630
|
yes: true,
|
|
623
631
|
isTty: false,
|
|
632
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
624
633
|
});
|
|
625
634
|
expect(code).toBe(0);
|
|
626
635
|
// The "nothing recognized" exit branch — no archive directory created.
|
|
@@ -647,6 +656,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
647
656
|
log: (l) => logs.push(l),
|
|
648
657
|
prompt: async () => "n",
|
|
649
658
|
isTty: true,
|
|
659
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
650
660
|
});
|
|
651
661
|
expect(code).toBe(1);
|
|
652
662
|
expect(logs.join("\n")).toMatch(/aborted/i);
|
|
@@ -668,6 +678,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
668
678
|
log: () => {},
|
|
669
679
|
prompt: async () => "y",
|
|
670
680
|
isTty: true,
|
|
681
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
671
682
|
});
|
|
672
683
|
expect(code).toBe(0);
|
|
673
684
|
expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
|
|
@@ -687,6 +698,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
687
698
|
log: () => {},
|
|
688
699
|
yes: true,
|
|
689
700
|
isTty: false,
|
|
701
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
690
702
|
});
|
|
691
703
|
// Add more cruft and sweep again the same day
|
|
692
704
|
touch(join(h.configDir, "channel.log"), "2");
|
|
@@ -696,6 +708,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
696
708
|
log: () => {},
|
|
697
709
|
yes: true,
|
|
698
710
|
isTty: false,
|
|
711
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
699
712
|
});
|
|
700
713
|
const archive = join(h.configDir, ".archive-2026-04-19");
|
|
701
714
|
expect(existsSync(join(archive, "server.yaml"))).toBe(true);
|
|
@@ -719,6 +732,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
719
732
|
log: () => {},
|
|
720
733
|
yes: true,
|
|
721
734
|
isTty: false,
|
|
735
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
722
736
|
});
|
|
723
737
|
touch(join(h.configDir, "channel.log"), "2");
|
|
724
738
|
await migrate({
|
|
@@ -727,6 +741,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
727
741
|
log: () => {},
|
|
728
742
|
yes: true,
|
|
729
743
|
isTty: false,
|
|
744
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
730
745
|
});
|
|
731
746
|
expect(existsSync(join(h.configDir, ".archive-2026-04-19", "server.yaml"))).toBe(true);
|
|
732
747
|
expect(existsSync(join(h.configDir, ".archive-2026-04-20", "channel.log"))).toBe(true);
|
|
@@ -749,6 +764,7 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
749
764
|
log: () => {},
|
|
750
765
|
yes: true,
|
|
751
766
|
isTty: false,
|
|
767
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
752
768
|
});
|
|
753
769
|
const archive = join(h.configDir, ".archive-2026-04-19");
|
|
754
770
|
const contents = readdirSync(archive);
|
|
@@ -3,6 +3,8 @@ import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import type { ExposeState } from "../expose-state.ts";
|
|
7
|
+
import { writeExposeState } from "../expose-state.ts";
|
|
6
8
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
9
|
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
8
10
|
import {
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
OPERATOR_TOKEN_SCOPE_SETS,
|
|
15
17
|
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
16
18
|
OPERATOR_TOKEN_TTL_SECONDS,
|
|
19
|
+
buildKnownIssuersForOperatorToken,
|
|
17
20
|
issueOperatorToken,
|
|
18
21
|
mintOperatorToken,
|
|
19
22
|
operatorTokenPath,
|
|
@@ -478,6 +481,280 @@ describe("useOperatorTokenWithAutoRotate (#213)", () => {
|
|
|
478
481
|
});
|
|
479
482
|
});
|
|
480
483
|
|
|
484
|
+
// hub#516 — the operator token's `iss` is the hub's PUBLIC origin after
|
|
485
|
+
// `parachute expose`, but callers resolve `issuer` inconsistently (status →
|
|
486
|
+
// loopback, lifecycle → public). The client-side validation now accepts the
|
|
487
|
+
// token if its `iss` is ANY of the hub's known origins (loopback aliases ∪
|
|
488
|
+
// expose-state public origin ∪ env), gated FIRST on the JWKS signature. These
|
|
489
|
+
// tests pin the four-corner matrix: public-iss accepted with loopback config
|
|
490
|
+
// (the status bug), loopback-iss accepted, foreign-iss rejected, and
|
|
491
|
+
// foreign-SIGNATURE rejected even when its iss is in the known set.
|
|
492
|
+
const PUBLIC_ISSUER = "https://parachute.taildf9ce2.ts.net";
|
|
493
|
+
|
|
494
|
+
/** Minimal valid expose-state advertising `hubOrigin` (the public origin). */
|
|
495
|
+
function exposeStateForOrigin(hubOrigin: string): ExposeState {
|
|
496
|
+
return {
|
|
497
|
+
version: 1,
|
|
498
|
+
layer: "tailnet",
|
|
499
|
+
mode: "path",
|
|
500
|
+
canonicalFqdn: new URL(hubOrigin).host,
|
|
501
|
+
port: 1939,
|
|
502
|
+
funnel: false,
|
|
503
|
+
entries: [],
|
|
504
|
+
hubOrigin,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
describe("useOperatorTokenWithAutoRotate known-issuer set (hub#516)", () => {
|
|
509
|
+
// The PARACHUTE_HUB_ORIGIN / RENDER_EXTERNAL_URL / FLY_APP_NAME env vars feed
|
|
510
|
+
// the platform-origin seed of the known-issuer set. Tests that assert a
|
|
511
|
+
// public-iss is REJECTED when expose-state is absent must not have a stray
|
|
512
|
+
// env public origin leaking the iss back in — clear them around each test.
|
|
513
|
+
function withCleanPlatformEnv<T>(fn: () => T): T {
|
|
514
|
+
const saved = {
|
|
515
|
+
hub: process.env.PARACHUTE_HUB_ORIGIN,
|
|
516
|
+
render: process.env.RENDER_EXTERNAL_URL,
|
|
517
|
+
fly: process.env.FLY_APP_NAME,
|
|
518
|
+
};
|
|
519
|
+
// Computed-key delete (not `delete process.env.FOO`) so biome's noDelete
|
|
520
|
+
// doesn't fire — matches spawn-env-propagation.test.ts. A `= undefined`
|
|
521
|
+
// assignment would coerce to the string "undefined" and leak a bogus
|
|
522
|
+
// origin into the known-issuer set, so a real delete is required here.
|
|
523
|
+
for (const k of ["PARACHUTE_HUB_ORIGIN", "RENDER_EXTERNAL_URL", "FLY_APP_NAME"]) {
|
|
524
|
+
delete process.env[k];
|
|
525
|
+
}
|
|
526
|
+
try {
|
|
527
|
+
return fn();
|
|
528
|
+
} finally {
|
|
529
|
+
if (saved.hub !== undefined) process.env.PARACHUTE_HUB_ORIGIN = saved.hub;
|
|
530
|
+
if (saved.render !== undefined) process.env.RENDER_EXTERNAL_URL = saved.render;
|
|
531
|
+
if (saved.fly !== undefined) process.env.FLY_APP_NAME = saved.fly;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
test("accepts a PUBLIC-iss operator token when config resolves loopback (the status bug)", async () => {
|
|
536
|
+
await withCleanPlatformEnv(async () => {
|
|
537
|
+
const h = makeHarness();
|
|
538
|
+
try {
|
|
539
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
540
|
+
try {
|
|
541
|
+
rotateSigningKey(db);
|
|
542
|
+
// Mint the operator token under the hub's PUBLIC origin — what
|
|
543
|
+
// happens on an exposed box (selfHealOperatorTokenIssuer re-mints to
|
|
544
|
+
// the public iss).
|
|
545
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
546
|
+
dir: h.dir,
|
|
547
|
+
issuer: PUBLIC_ISSUER,
|
|
548
|
+
});
|
|
549
|
+
// Expose-state advertises the public origin (so it lands in the
|
|
550
|
+
// known set). The CALLER resolves loopback (status's hardcoded path).
|
|
551
|
+
writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
|
|
552
|
+
|
|
553
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
554
|
+
configDir: h.dir,
|
|
555
|
+
issuer: TEST_ISSUER, // loopback — the status scenario
|
|
556
|
+
});
|
|
557
|
+
expect(used).not.toBeNull();
|
|
558
|
+
expect(used?.status.kind).toBe("fresh");
|
|
559
|
+
expect(used?.token).toBe(issued.token);
|
|
560
|
+
expect(used?.payload.iss).toBe(PUBLIC_ISSUER);
|
|
561
|
+
} finally {
|
|
562
|
+
db.close();
|
|
563
|
+
}
|
|
564
|
+
} finally {
|
|
565
|
+
h.cleanup();
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("accepts a loopback-iss operator token with loopback config", async () => {
|
|
571
|
+
await withCleanPlatformEnv(async () => {
|
|
572
|
+
const h = makeHarness();
|
|
573
|
+
try {
|
|
574
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
575
|
+
try {
|
|
576
|
+
rotateSigningKey(db);
|
|
577
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
578
|
+
dir: h.dir,
|
|
579
|
+
issuer: TEST_ISSUER,
|
|
580
|
+
});
|
|
581
|
+
// No expose-state — known set is loopback aliases only.
|
|
582
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
583
|
+
configDir: h.dir,
|
|
584
|
+
issuer: TEST_ISSUER,
|
|
585
|
+
});
|
|
586
|
+
expect(used).not.toBeNull();
|
|
587
|
+
expect(used?.status.kind).toBe("fresh");
|
|
588
|
+
expect(used?.token).toBe(issued.token);
|
|
589
|
+
} finally {
|
|
590
|
+
db.close();
|
|
591
|
+
}
|
|
592
|
+
} finally {
|
|
593
|
+
h.cleanup();
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("rejects a token whose iss is FOREIGN to the known set", async () => {
|
|
599
|
+
await withCleanPlatformEnv(async () => {
|
|
600
|
+
const h = makeHarness();
|
|
601
|
+
try {
|
|
602
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
603
|
+
try {
|
|
604
|
+
rotateSigningKey(db);
|
|
605
|
+
// Hub-SIGNED (so the signature gate passes) but stamped with an iss
|
|
606
|
+
// that's neither loopback nor in expose-state nor env. Must reject.
|
|
607
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
608
|
+
dir: h.dir,
|
|
609
|
+
issuer: "https://evil.example.com",
|
|
610
|
+
});
|
|
611
|
+
expect(issued.token.length).toBeGreaterThan(0);
|
|
612
|
+
// expose-state advertises the PUBLIC origin (not the evil one), so
|
|
613
|
+
// the foreign iss is not in the known set.
|
|
614
|
+
writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
|
|
615
|
+
|
|
616
|
+
await expect(
|
|
617
|
+
useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
|
|
618
|
+
).rejects.toThrow(/unexpected "iss" claim value/);
|
|
619
|
+
} finally {
|
|
620
|
+
db.close();
|
|
621
|
+
}
|
|
622
|
+
} finally {
|
|
623
|
+
h.cleanup();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("rejects a FOREIGN-SIGNED token even when its iss is in the known set (signature gate first)", async () => {
|
|
629
|
+
await withCleanPlatformEnv(async () => {
|
|
630
|
+
const h = makeHarness();
|
|
631
|
+
const foreign = makeHarness();
|
|
632
|
+
try {
|
|
633
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
634
|
+
// A DIFFERENT hub (different signing key) mints a token stamped with an
|
|
635
|
+
// iss that IS in our known set. The signature won't verify against our
|
|
636
|
+
// JWKS, so it must be rejected at the signature gate regardless of iss.
|
|
637
|
+
const foreignDb = openHubDb(hubDbPath(foreign.dir));
|
|
638
|
+
try {
|
|
639
|
+
rotateSigningKey(db);
|
|
640
|
+
rotateSigningKey(foreignDb);
|
|
641
|
+
const foreignToken = await mintOperatorToken(foreignDb, "user-abc", {
|
|
642
|
+
issuer: PUBLIC_ISSUER,
|
|
643
|
+
});
|
|
644
|
+
await writeOperatorTokenFile(foreignToken.token, h.dir);
|
|
645
|
+
// Our expose-state advertises PUBLIC_ISSUER — so the iss WOULD pass
|
|
646
|
+
// the belt-and-suspenders check. The signature gate must still reject.
|
|
647
|
+
writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
|
|
648
|
+
|
|
649
|
+
await expect(
|
|
650
|
+
useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
|
|
651
|
+
).rejects.toThrow();
|
|
652
|
+
} finally {
|
|
653
|
+
db.close();
|
|
654
|
+
foreignDb.close();
|
|
655
|
+
}
|
|
656
|
+
} finally {
|
|
657
|
+
h.cleanup();
|
|
658
|
+
foreign.cleanup();
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("expose-state absent: loopback-iss accepted, public-iss rejected (no public origin known)", async () => {
|
|
664
|
+
await withCleanPlatformEnv(async () => {
|
|
665
|
+
const h = makeHarness();
|
|
666
|
+
try {
|
|
667
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
668
|
+
try {
|
|
669
|
+
rotateSigningKey(db);
|
|
670
|
+
// A loopback-iss token is accepted (loopback alias is always in the set).
|
|
671
|
+
const loopbackTok = await issueOperatorToken(db, "user-abc", {
|
|
672
|
+
dir: h.dir,
|
|
673
|
+
issuer: TEST_ISSUER,
|
|
674
|
+
});
|
|
675
|
+
const usedLoopback = await useOperatorTokenWithAutoRotate(db, {
|
|
676
|
+
configDir: h.dir,
|
|
677
|
+
issuer: TEST_ISSUER,
|
|
678
|
+
});
|
|
679
|
+
expect(usedLoopback?.token).toBe(loopbackTok.token);
|
|
680
|
+
|
|
681
|
+
// Overwrite with a public-iss token. No expose-state, no env public
|
|
682
|
+
// origin → the public iss is NOT known → reject. (Correct: with no
|
|
683
|
+
// exposure configured, the hub doesn't legitimately answer on it.)
|
|
684
|
+
const publicTok = await issueOperatorToken(db, "user-abc", {
|
|
685
|
+
dir: h.dir,
|
|
686
|
+
issuer: PUBLIC_ISSUER,
|
|
687
|
+
});
|
|
688
|
+
expect(publicTok.token.length).toBeGreaterThan(0);
|
|
689
|
+
await expect(
|
|
690
|
+
useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
|
|
691
|
+
).rejects.toThrow(/unexpected "iss" claim value/);
|
|
692
|
+
} finally {
|
|
693
|
+
db.close();
|
|
694
|
+
}
|
|
695
|
+
} finally {
|
|
696
|
+
h.cleanup();
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("auto-rotate still fires for a near-expiry token validated via the known set", async () => {
|
|
702
|
+
await withCleanPlatformEnv(async () => {
|
|
703
|
+
const h = makeHarness();
|
|
704
|
+
try {
|
|
705
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
706
|
+
try {
|
|
707
|
+
rotateSigningKey(db);
|
|
708
|
+
// Public-iss + 1-day TTL (below the 7d threshold). Validates via the
|
|
709
|
+
// known set (expose-state public origin), then auto-rotates.
|
|
710
|
+
const original = await issueOperatorToken(db, "user-abc", {
|
|
711
|
+
dir: h.dir,
|
|
712
|
+
issuer: PUBLIC_ISSUER,
|
|
713
|
+
scopeSet: "start",
|
|
714
|
+
ttlSeconds: 24 * 60 * 60,
|
|
715
|
+
});
|
|
716
|
+
writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
|
|
717
|
+
|
|
718
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
719
|
+
configDir: h.dir,
|
|
720
|
+
issuer: PUBLIC_ISSUER, // lifecycle's public-origin scenario
|
|
721
|
+
});
|
|
722
|
+
expect(used).not.toBeNull();
|
|
723
|
+
expect(used?.status.kind).toBe("rotated");
|
|
724
|
+
expect(used?.rotated?.scopeSet).toBe("start");
|
|
725
|
+
expect(used?.token).not.toBe(original.token);
|
|
726
|
+
// Re-mint stamps opts.issuer as the new iss; still validates.
|
|
727
|
+
const validated = await validateAccessToken(db, used!.token, PUBLIC_ISSUER);
|
|
728
|
+
expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
|
|
729
|
+
} finally {
|
|
730
|
+
db.close();
|
|
731
|
+
}
|
|
732
|
+
} finally {
|
|
733
|
+
h.cleanup();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("buildKnownIssuersForOperatorToken includes loopback aliases + expose-state public origin", async () => {
|
|
739
|
+
await withCleanPlatformEnv(() => {
|
|
740
|
+
const h = makeHarness();
|
|
741
|
+
try {
|
|
742
|
+
writeExposeState(exposeStateForOrigin(PUBLIC_ISSUER), join(h.dir, "expose-state.json"));
|
|
743
|
+
const set = buildKnownIssuersForOperatorToken(h.dir, TEST_ISSUER);
|
|
744
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
745
|
+
expect(set).toContain("http://localhost:1939");
|
|
746
|
+
expect(set).toContain(PUBLIC_ISSUER);
|
|
747
|
+
// The seed issuer is included too.
|
|
748
|
+
expect(set).toContain(TEST_ISSUER);
|
|
749
|
+
// A foreign origin is NOT present.
|
|
750
|
+
expect(set).not.toContain("https://evil.example.com");
|
|
751
|
+
} finally {
|
|
752
|
+
h.cleanup();
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
|
|
481
758
|
// closes #212 Phase 1 — operator-mint paths write to the unified token
|
|
482
759
|
// registry so they show up in the revocation list and admin UI alongside
|
|
483
760
|
// OAuth refresh tokens and CLI mints.
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -40,8 +40,8 @@ import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
|
40
40
|
import { isLinked as defaultIsLinked } from "./bun-link.ts";
|
|
41
41
|
import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
|
|
42
42
|
import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
|
|
43
|
+
import { validateHostAdminToken } from "./host-admin-token-validation.ts";
|
|
43
44
|
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
44
|
-
import { validateAccessToken } from "./jwt-sign.ts";
|
|
45
45
|
import { readModuleManifest } from "./module-manifest.ts";
|
|
46
46
|
import { refreshWellKnown, stampInstallDirOnRow } from "./post-install.ts";
|
|
47
47
|
import {
|
|
@@ -180,6 +180,21 @@ export interface RunOpts {
|
|
|
180
180
|
export interface ApiModulesOpsDeps {
|
|
181
181
|
db: Database;
|
|
182
182
|
issuer: string;
|
|
183
|
+
/**
|
|
184
|
+
* The SET of origins the hub legitimately answers on — loopback aliases ∪
|
|
185
|
+
* expose-state public origin ∪ platform/env origin ∪ the per-request
|
|
186
|
+
* `issuer`. The host-admin bearer's `iss` is validated against THIS set, not
|
|
187
|
+
* the single per-request `issuer` (hub#516): the CLI drives these endpoints
|
|
188
|
+
* on loopback presenting the operator token, whose `iss` is the hub's public
|
|
189
|
+
* origin after `expose`. Built via `buildHubBoundOrigins` at the call site.
|
|
190
|
+
*
|
|
191
|
+
* Optional for back-compat with callers that don't construct it (the
|
|
192
|
+
* first-boot wizard's `runInstall`, tests). When absent, `authorize` falls
|
|
193
|
+
* back to the single-element `[issuer]` set — i.e. the prior strict
|
|
194
|
+
* per-request behavior — so the relaxation is opt-in at the HTTP call site
|
|
195
|
+
* and the non-HTTP install path is unaffected.
|
|
196
|
+
*/
|
|
197
|
+
knownIssuers?: readonly string[];
|
|
183
198
|
manifestPath: string;
|
|
184
199
|
configDir: string;
|
|
185
200
|
supervisor: Supervisor;
|
|
@@ -280,7 +295,18 @@ async function authorize(req: Request, deps: ApiModulesOpsDeps): Promise<Respons
|
|
|
280
295
|
const bearer = auth.slice("Bearer ".length).trim();
|
|
281
296
|
if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
|
|
282
297
|
try {
|
|
283
|
-
|
|
298
|
+
// Host-admin (operator / SPA) token validation: accept the `iss` against
|
|
299
|
+
// the SET of origins the hub answers on, not the single per-request issuer
|
|
300
|
+
// (hub#516). This surface only ever accepts the hub's own self-issued
|
|
301
|
+
// host-admin credentials (the `parachute:host:admin` scope below is
|
|
302
|
+
// non-requestable via OAuth), so the relaxation cannot reach an OAuth
|
|
303
|
+
// token's validation. Falls back to the strict single-issuer set when
|
|
304
|
+
// `knownIssuers` isn't wired (non-HTTP install path / tests).
|
|
305
|
+
const validated = await validateHostAdminToken(
|
|
306
|
+
deps.db,
|
|
307
|
+
bearer,
|
|
308
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
309
|
+
);
|
|
284
310
|
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
285
311
|
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
286
312
|
}
|
package/src/api-modules.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { Database } from "bun:sqlite";
|
|
28
|
+
import { validateHostAdminToken } from "./host-admin-token-validation.ts";
|
|
28
29
|
import {
|
|
29
30
|
type ModuleInstallChannel,
|
|
30
31
|
getModuleInstallChannel,
|
|
@@ -116,6 +117,18 @@ export type CuratedModuleShort = (typeof CURATED_MODULES)[number];
|
|
|
116
117
|
export interface ApiModulesDeps {
|
|
117
118
|
db: Database;
|
|
118
119
|
issuer: string;
|
|
120
|
+
/**
|
|
121
|
+
* The SET of origins the hub legitimately answers on — loopback aliases ∪
|
|
122
|
+
* expose-state public origin ∪ platform/env origin ∪ the per-request
|
|
123
|
+
* `issuer`. The host-admin bearer's `iss` is validated against THIS set, not
|
|
124
|
+
* the single per-request `issuer` (hub#516): `parachute status` reads this
|
|
125
|
+
* endpoint on loopback presenting the operator token, whose `iss` is the
|
|
126
|
+
* hub's public origin after `expose`. Built via `buildHubBoundOrigins` at the
|
|
127
|
+
* call site. When absent, falls back to the single-element `[issuer]` set
|
|
128
|
+
* (the prior strict per-request behavior) so non-HTTP callers / tests are
|
|
129
|
+
* unaffected.
|
|
130
|
+
*/
|
|
131
|
+
knownIssuers?: readonly string[];
|
|
119
132
|
manifestPath: string;
|
|
120
133
|
supervisor?: Supervisor;
|
|
121
134
|
/**
|
|
@@ -312,10 +325,20 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
312
325
|
return jsonError(401, "unauthenticated", "empty bearer token");
|
|
313
326
|
}
|
|
314
327
|
|
|
315
|
-
// Bearer validation.
|
|
328
|
+
// Bearer validation. Host-admin (operator / SPA) token: accept the `iss`
|
|
329
|
+
// against the SET of origins the hub answers on, not the single per-request
|
|
330
|
+
// issuer (hub#516) — `parachute status` reads this on loopback presenting the
|
|
331
|
+
// operator token, whose `iss` is the hub's public origin after `expose`. This
|
|
332
|
+
// surface gates on the non-requestable `parachute:host:auth` scope below, so
|
|
333
|
+
// the relaxation only ever touches the hub's own self-issued host-admin
|
|
334
|
+
// credentials and cannot reach an OAuth token's validation.
|
|
316
335
|
let bearerScopes: string[];
|
|
317
336
|
try {
|
|
318
|
-
const validated = await
|
|
337
|
+
const validated = await validateHostAdminToken(
|
|
338
|
+
deps.db,
|
|
339
|
+
bearer,
|
|
340
|
+
deps.knownIssuers ?? [deps.issuer],
|
|
341
|
+
);
|
|
319
342
|
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
320
343
|
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
321
344
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issuer validation for the hub's OWN host-admin credentials (the operator
|
|
3
|
+
* token + the SPA host-admin token) on the loopback module-ops surfaces.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is its own helper (hub#516)
|
|
6
|
+
*
|
|
7
|
+
* The on-box CLI drives the hub on loopback (`127.0.0.1:1939`) presenting
|
|
8
|
+
* `~/.parachute/operator.token` — a hub-SELF-issued JWT (`aud: "operator"`,
|
|
9
|
+
* scope-set carries `parachute:host:admin`). After `parachute expose`, the
|
|
10
|
+
* operator token's `iss` is the hub's PUBLIC origin (e.g.
|
|
11
|
+
* `https://parachute.taildf9ce2.ts.net`), because §3.1 self-heals it there so
|
|
12
|
+
* on-box services validating public-origin bearers accept it.
|
|
13
|
+
*
|
|
14
|
+
* But the hub resolves its issuer PER-REQUEST from the Host header
|
|
15
|
+
* (`resolveIssuer` in hub-server.ts, "closes #245") — so a LOOPBACK request
|
|
16
|
+
* resolves the issuer to `http://127.0.0.1:1939`. The strict per-request
|
|
17
|
+
* `validateAccessToken(db, token, <loopback-issuer>)` then rejects the
|
|
18
|
+
* operator token's PUBLIC `iss` as `unexpected "iss" claim value`. Net:
|
|
19
|
+
* `parachute status` / `start|stop|restart <svc>` fail on ANY exposed box
|
|
20
|
+
* (tailnet or Cloudflare), even though the credential is the hub's own,
|
|
21
|
+
* presented on the hub's own loopback.
|
|
22
|
+
*
|
|
23
|
+
* ## The scoped relaxation
|
|
24
|
+
*
|
|
25
|
+
* The operator token (and the SPA host-admin token) are SELF-issued — the hub
|
|
26
|
+
* signs them with its own key, and {@link validateAccessToken} verifies that
|
|
27
|
+
* signature against the hub's JWKS. The signature already proves provenance:
|
|
28
|
+
* the only tokens that can verify are ones THIS hub minted. So for these
|
|
29
|
+
* host-admin credentials, the `iss` claim should be accepted if it matches ANY
|
|
30
|
+
* origin the hub legitimately answers on — loopback ∪ expose-state public
|
|
31
|
+
* origin ∪ platform/env origin — not just the single per-request one.
|
|
32
|
+
*
|
|
33
|
+
* We deliberately do NOT drop the `iss` check entirely (belt-and-suspenders):
|
|
34
|
+
* a token whose `iss` is none of the hub's known origins is still rejected,
|
|
35
|
+
* so a hypothetical hub-signed token minted for a DIFFERENT origin can't be
|
|
36
|
+
* replayed here.
|
|
37
|
+
*
|
|
38
|
+
* ## What this does NOT touch
|
|
39
|
+
*
|
|
40
|
+
* OAuth / access-token validation (vault / MCP tokens, `aud: "vault.<name>"`)
|
|
41
|
+
* stays STRICT per-request-issuer and lives on entirely separate code paths
|
|
42
|
+
* (the resource servers' own validators, hub's `/api/auth/*`, etc.). This
|
|
43
|
+
* helper is invoked ONLY from the two loopback host-admin module surfaces
|
|
44
|
+
* (`/api/modules` GET — the `status` read; `/api/modules/:short/*` POST — the
|
|
45
|
+
* lifecycle ops), both of which already gate on the non-requestable
|
|
46
|
+
* `parachute:host:admin` / `parachute:host:auth` scopes that no OAuth token
|
|
47
|
+
* can carry. The relaxation cannot reach an OAuth token's validation.
|
|
48
|
+
*/
|
|
49
|
+
import type { Database } from "bun:sqlite";
|
|
50
|
+
import { type ValidatedAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate a host-admin bearer (operator token / SPA host-admin token)
|
|
54
|
+
* presented on a loopback module surface, accepting its `iss` against the SET
|
|
55
|
+
* of origins the hub legitimately answers on rather than the single
|
|
56
|
+
* per-request issuer.
|
|
57
|
+
*
|
|
58
|
+
* Verification order:
|
|
59
|
+
* 1. Signature + `exp` + revocation, via {@link validateAccessToken} WITHOUT
|
|
60
|
+
* an `expectedIssuer` — the signature proves the hub minted it (only this
|
|
61
|
+
* hub's key can produce a JWS that verifies against its JWKS). A throw
|
|
62
|
+
* here (bad/unknown/expired kid, jose `exp`, revoked jti) propagates
|
|
63
|
+
* unchanged.
|
|
64
|
+
* 2. `iss` ∈ `knownIssuers` — belt-and-suspenders. Even though the signature
|
|
65
|
+
* proves provenance, we still require the issuer to be one of the hub's
|
|
66
|
+
* own origins. A foreign/garbage `iss` throws (matching the per-request
|
|
67
|
+
* strict check's message shape so callers' error rendering is unchanged).
|
|
68
|
+
*
|
|
69
|
+
* `knownIssuers` is the hub's own valid origin set — typically built from
|
|
70
|
+
* `buildHubBoundOrigins` (per-request issuer ∪ loopback aliases ∪
|
|
71
|
+
* expose-state public origin ∪ platform/env origin). Empty/garbage entries are
|
|
72
|
+
* the caller's responsibility to filter; an empty set rejects every token
|
|
73
|
+
* (fails closed).
|
|
74
|
+
*
|
|
75
|
+
* @throws Error when the signature/exp/revocation check fails, or when `iss`
|
|
76
|
+
* is absent / not a string / not in `knownIssuers`.
|
|
77
|
+
*/
|
|
78
|
+
export async function validateHostAdminToken(
|
|
79
|
+
db: Database,
|
|
80
|
+
token: string,
|
|
81
|
+
knownIssuers: readonly string[],
|
|
82
|
+
): Promise<ValidatedAccessToken> {
|
|
83
|
+
// Step 1: signature + exp + revocation, NOT pinning iss. Provenance is
|
|
84
|
+
// proved by the signature verifying against the hub's own JWKS.
|
|
85
|
+
const validated = await validateAccessToken(db, token);
|
|
86
|
+
|
|
87
|
+
// Step 2: belt-and-suspenders iss ∈ known-origins. Never widen to arbitrary
|
|
88
|
+
// issuers — the token's iss must be one of the hub's own legitimate origins.
|
|
89
|
+
const iss = validated.payload.iss;
|
|
90
|
+
if (typeof iss !== "string" || !knownIssuers.includes(iss)) {
|
|
91
|
+
// Mirror jose's wording so the CLI's bearer-invalid error path renders the
|
|
92
|
+
// same way it did for the strict per-request check.
|
|
93
|
+
throw new Error('unexpected "iss" claim value');
|
|
94
|
+
}
|
|
95
|
+
return validated;
|
|
96
|
+
}
|
package/src/hub-server.ts
CHANGED
|
@@ -1783,9 +1783,16 @@ export function hubFetch(
|
|
|
1783
1783
|
|
|
1784
1784
|
if (pathname === "/api/modules") {
|
|
1785
1785
|
if (!getDb) return dbNotConfigured();
|
|
1786
|
+
const od = oauthDeps(req);
|
|
1786
1787
|
const modulesDeps: Parameters<typeof handleApiModules>[1] = {
|
|
1787
1788
|
db: getDb(),
|
|
1788
|
-
issuer:
|
|
1789
|
+
issuer: od.issuer,
|
|
1790
|
+
// hub#516: validate the host-admin bearer's `iss` against the SET of
|
|
1791
|
+
// origins the hub answers on (loopback ∪ expose-state ∪ env/platform ∪
|
|
1792
|
+
// per-request issuer), so `parachute status` works on an exposed box
|
|
1793
|
+
// where the operator token carries the public origin but the loopback
|
|
1794
|
+
// request resolves the loopback issuer.
|
|
1795
|
+
knownIssuers: od.hubBoundOrigins(),
|
|
1789
1796
|
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
1790
1797
|
};
|
|
1791
1798
|
if (deps?.supervisor !== undefined) modulesDeps.supervisor = deps.supervisor;
|
|
@@ -1841,9 +1848,13 @@ export function hubFetch(
|
|
|
1841
1848
|
}
|
|
1842
1849
|
const opId = decodeURIComponent(pathname.slice("/api/modules/operations/".length));
|
|
1843
1850
|
if (!opId || opId.includes("/")) return new Response("not found", { status: 404 });
|
|
1851
|
+
const od = oauthDeps(req);
|
|
1844
1852
|
return handleOperationGet(req, opId, {
|
|
1845
1853
|
db: getDb(),
|
|
1846
|
-
issuer:
|
|
1854
|
+
issuer: od.issuer,
|
|
1855
|
+
// hub#516: see the `/api/modules` deps note — the CLI polls async ops
|
|
1856
|
+
// on loopback with the operator token (public `iss`).
|
|
1857
|
+
knownIssuers: od.hubBoundOrigins(),
|
|
1847
1858
|
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
1848
1859
|
configDir: CONFIG_DIR,
|
|
1849
1860
|
supervisor: deps.supervisor,
|
|
@@ -1888,9 +1899,14 @@ export function hubFetch(
|
|
|
1888
1899
|
}
|
|
1889
1900
|
const match = parseModulesPath(pathname);
|
|
1890
1901
|
if (!match) return new Response("not found", { status: 404 });
|
|
1902
|
+
const od = oauthDeps(req);
|
|
1891
1903
|
const opsDeps = {
|
|
1892
1904
|
db: getDb(),
|
|
1893
|
-
issuer:
|
|
1905
|
+
issuer: od.issuer,
|
|
1906
|
+
// hub#516: the CLI drives start/stop/restart/install/upgrade/uninstall
|
|
1907
|
+
// on loopback with the operator token, whose `iss` is the hub's public
|
|
1908
|
+
// origin after `expose`. Validate against the hub's known-origin set.
|
|
1909
|
+
knownIssuers: od.hubBoundOrigins(),
|
|
1894
1910
|
manifestPath: deps?.manifestPath ?? SERVICES_MANIFEST_PATH,
|
|
1895
1911
|
configDir: CONFIG_DIR,
|
|
1896
1912
|
supervisor: deps.supervisor,
|
package/src/operator-token.ts
CHANGED
|
@@ -30,7 +30,12 @@ import type { Database } from "bun:sqlite";
|
|
|
30
30
|
import { promises as fs } from "node:fs";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { configDir } from "./config.ts";
|
|
33
|
+
import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
|
|
34
|
+
import { validateHostAdminToken } from "./host-admin-token-validation.ts";
|
|
35
|
+
import { readHubPort } from "./hub-control.ts";
|
|
36
|
+
import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
|
|
33
37
|
import { recordTokenMint, signAccessToken, validateAccessToken } from "./jwt-sign.ts";
|
|
38
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
34
39
|
import { isLoopbackOrigin } from "./vault-hub-origin-env.ts";
|
|
35
40
|
|
|
36
41
|
export const OPERATOR_TOKEN_FILENAME = "operator.token";
|
|
@@ -279,7 +284,14 @@ export class OperatorTokenExpiredError extends Error {
|
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
export interface UseOperatorTokenOpts {
|
|
282
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Hub origin the caller resolved. Required. As of hub#516 this is a SEED of
|
|
289
|
+
* the known-issuer SET the token's `iss` is validated against
|
|
290
|
+
* ({@link buildKnownIssuersForOperatorToken}), not the sole `iss` validator —
|
|
291
|
+
* so a caller that resolves loopback (`status`) still accepts a public-`iss`
|
|
292
|
+
* operator token from `expose-state.json`, and vice versa. It also remains
|
|
293
|
+
* the `iss` stamped on an auto-rotated re-mint.
|
|
294
|
+
*/
|
|
283
295
|
issuer: string;
|
|
284
296
|
/** configDir override (where operator.token lives). Defaults to `configDir()`. */
|
|
285
297
|
configDir?: string;
|
|
@@ -345,9 +357,78 @@ export interface UsedOperatorToken {
|
|
|
345
357
|
status: RotationStatus;
|
|
346
358
|
}
|
|
347
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Compose the Fly.io default public origin from `FLY_APP_NAME`, mirroring the
|
|
362
|
+
* server-side `flyDefaultOrigin` (hub-server.ts) so the client-side
|
|
363
|
+
* known-issuer set matches the origin the hub stamps tokens with on Fly. Kept
|
|
364
|
+
* local (a one-liner) rather than imported to avoid pulling hub-server.ts /
|
|
365
|
+
* serve.ts into the CLI auth path. Fly slugs never contain `/`; anything with
|
|
366
|
+
* one is spoofed or malformed.
|
|
367
|
+
*/
|
|
368
|
+
function flyDefaultOrigin(env: NodeJS.ProcessEnv): string | undefined {
|
|
369
|
+
const app = env.FLY_APP_NAME;
|
|
370
|
+
if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
|
|
371
|
+
return `https://${app}.fly.dev`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Assemble the SET of origins this hub legitimately answers on, from on-disk
|
|
376
|
+
* client state — the client-side mirror of hub-server.ts's per-request
|
|
377
|
+
* `buildHubBoundOrigins` call (hub#516). The operator token's `iss` is
|
|
378
|
+
* validated against this set rather than a single issuer, so a token whose
|
|
379
|
+
* `iss` is the hub's PUBLIC origin (stamped after `parachute expose`) is
|
|
380
|
+
* accepted even when the CLI command resolved a loopback `issuer` (the
|
|
381
|
+
* `status` case) — and vice versa.
|
|
382
|
+
*
|
|
383
|
+
* The set is:
|
|
384
|
+
* - `seedIssuer` — the issuer the caller resolved (loopback for `status`,
|
|
385
|
+
* `r.hubOrigin` / public for lifecycle). Kept as a seed so callers that
|
|
386
|
+
* pass a value still contribute it; never the sole gate.
|
|
387
|
+
* - loopback aliases — `http://127.0.0.1:<port>` AND `http://localhost:<port>`
|
|
388
|
+
* for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`),
|
|
389
|
+
* matching the hub's own loopback alias set (`buildHubBoundOrigins`).
|
|
390
|
+
* - the expose-state public origin — `expose-state.json`'s `hubOrigin`, the
|
|
391
|
+
* public URL the hub stamps on tokens once exposed.
|
|
392
|
+
* - the platform/env public origin — `PARACHUTE_HUB_ORIGIN` ∪
|
|
393
|
+
* `RENDER_EXTERNAL_URL` ∪ the composed Fly default — for container deploys
|
|
394
|
+
* where the public origin comes from the platform, not expose-state.
|
|
395
|
+
*
|
|
396
|
+
* Provenance is NOT established here: `validateHostAdminToken` runs the JWKS
|
|
397
|
+
* signature check FIRST and unconditionally. This set is the belt-and-suspenders
|
|
398
|
+
* `iss` allowlist layered on top — a foreign `iss` (not loopback / expose /
|
|
399
|
+
* env) is rejected, and an empty set fails closed.
|
|
400
|
+
*/
|
|
401
|
+
export function buildKnownIssuersForOperatorToken(
|
|
402
|
+
configDirOverride: string | undefined,
|
|
403
|
+
seedIssuer: string,
|
|
404
|
+
): readonly string[] {
|
|
405
|
+
const dir = configDirOverride ?? configDir();
|
|
406
|
+
const loopbackPort = readHubPort(dir) ?? HUB_UNIT_DEFAULT_PORT;
|
|
407
|
+
let exposeHubOrigin: string | undefined;
|
|
408
|
+
try {
|
|
409
|
+
exposeHubOrigin = readExposeState(join(dir, "expose-state.json"))?.hubOrigin;
|
|
410
|
+
} catch {
|
|
411
|
+
// A malformed expose-state.json must never lock the operator out of the
|
|
412
|
+
// CLI — the seed issuer + loopback aliases already cover legitimate
|
|
413
|
+
// loopback access; treat it as "no public origin known."
|
|
414
|
+
exposeHubOrigin = undefined;
|
|
415
|
+
}
|
|
416
|
+
const platformOrigin =
|
|
417
|
+
process.env.PARACHUTE_HUB_ORIGIN ??
|
|
418
|
+
process.env.RENDER_EXTERNAL_URL ??
|
|
419
|
+
flyDefaultOrigin(process.env);
|
|
420
|
+
return buildHubBoundOrigins({
|
|
421
|
+
issuer: seedIssuer,
|
|
422
|
+
loopbackPort,
|
|
423
|
+
...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
|
|
424
|
+
...(platformOrigin !== undefined ? { platformOrigin } : {}),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
348
428
|
/**
|
|
349
429
|
* The canonical "use the operator token in a CLI flow" helper. Reads
|
|
350
|
-
* `~/.parachute/operator.token`, validates against `db` +
|
|
430
|
+
* `~/.parachute/operator.token`, validates against `db` + the hub's
|
|
431
|
+
* known-issuer SET, and:
|
|
351
432
|
*
|
|
352
433
|
* - If the token has fully expired: throws `OperatorTokenExpiredError`
|
|
353
434
|
* with an actionable message. Does NOT auto-rotate from a dead token —
|
|
@@ -397,9 +478,19 @@ export async function useOperatorTokenWithAutoRotate(
|
|
|
397
478
|
if (!token) return null;
|
|
398
479
|
const now = opts.now ?? (() => new Date());
|
|
399
480
|
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
481
|
+
// Validate against the hub's KNOWN-ISSUER SET, not a single `opts.issuer`
|
|
482
|
+
// (hub#516). The operator token is the hub's OWN self-issued credential; its
|
|
483
|
+
// `iss` is the hub's loopback origin before `expose` and its PUBLIC origin
|
|
484
|
+
// after. Callers resolve `opts.issuer` inconsistently — `status` hardcodes
|
|
485
|
+
// loopback, lifecycle uses `r.hubOrigin` (public when exposed) — so a single
|
|
486
|
+
// per-issuer check rejected the public-`iss` operator token on `status` even
|
|
487
|
+
// though `restart` worked. `validateHostAdminToken` (the #517 helper) gates
|
|
488
|
+
// on the JWKS SIGNATURE first+unconditionally (provenance), then accepts the
|
|
489
|
+
// `iss` if it's ANY origin the hub legitimately answers on. Validation
|
|
490
|
+
// failures (signature mismatch, missing kid, expired-by-jose, revoked, or an
|
|
491
|
+
// `iss` foreign to the whole set) bubble out for the caller to render.
|
|
492
|
+
const knownIssuers = buildKnownIssuersForOperatorToken(opts.configDir, opts.issuer);
|
|
493
|
+
const validated = await validateHostAdminToken(db, token, knownIssuers);
|
|
403
494
|
const { payload } = validated;
|
|
404
495
|
|
|
405
496
|
const exp = typeof payload.exp === "number" ? payload.exp : 0;
|
package/src/origin-check.ts
CHANGED
|
@@ -74,6 +74,16 @@ export function buildHubBoundOrigins(opts: {
|
|
|
74
74
|
// Malformed URL — skip.
|
|
75
75
|
}
|
|
76
76
|
};
|
|
77
|
+
// `opts.issuer` is the PER-REQUEST issuer, which `resolveIssuer` derives
|
|
78
|
+
// from the request's Host header (hub-server.ts, "closes #245"). Including a
|
|
79
|
+
// Host-derived value in the known-issuers set is SAFE — it is NOT a
|
|
80
|
+
// forged-`iss` bypass. Token provenance is signature-gated: the JWKS verify
|
|
81
|
+
// in `validateAccessToken` / `validateHostAdminToken` runs UNCONDITIONALLY
|
|
82
|
+
// FIRST, before `iss` is ever checked against this set. So a token whose
|
|
83
|
+
// `iss` matches an attacker-injected Host (and thus lands in this set) but
|
|
84
|
+
// which isn't signed by THIS hub's key is still rejected at the signature
|
|
85
|
+
// step. The known-issuers membership check is belt-and-suspenders layered on
|
|
86
|
+
// top of the signature gate, never a substitute for it.
|
|
77
87
|
add(opts.issuer);
|
|
78
88
|
add(opts.exposeHubOrigin);
|
|
79
89
|
add(opts.platformOrigin);
|