@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
|
@@ -20,6 +20,7 @@ const PARAMS: AuthorizeFormParams = {
|
|
|
20
20
|
codeChallenge: "ch",
|
|
21
21
|
codeChallengeMethod: "S256",
|
|
22
22
|
state: "xyz",
|
|
23
|
+
resource: null,
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const CSRF = "csrf-token-fixture";
|
|
@@ -50,9 +51,19 @@ describe("renderHiddenInputs", () => {
|
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
test("escapes hostile values into hidden inputs", () => {
|
|
53
|
-
const html = renderHiddenInputs({
|
|
54
|
+
const html = renderHiddenInputs({
|
|
55
|
+
...PARAMS,
|
|
56
|
+
state: `"><script>alert(1)</script>`,
|
|
57
|
+
// RFC 8707 resource is round-tripped through a hidden input too, so a
|
|
58
|
+
// hostile value must be escaped the same way.
|
|
59
|
+
resource: `"><script>alert(2)</script>`,
|
|
60
|
+
});
|
|
54
61
|
expect(html).not.toContain("<script>alert(1)</script>");
|
|
62
|
+
expect(html).not.toContain("<script>alert(2)</script>");
|
|
55
63
|
expect(html).toContain("<script>");
|
|
64
|
+
// The resource value is emitted as a hidden input (escaped).
|
|
65
|
+
expect(html).toContain('name="resource"');
|
|
66
|
+
expect(html).toContain("alert(2)");
|
|
56
67
|
});
|
|
57
68
|
});
|
|
58
69
|
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hub#481 — `selfHealOperatorTokenIssuer` re-mints a genuine-but-stale
|
|
3
|
+
* operator token under the hub's current issuer.
|
|
4
|
+
*
|
|
5
|
+
* Background: a box that ran init/setup at loopback and was LATER exposed
|
|
6
|
+
* publicly carries an `operator.token` whose `iss` (e.g. `http://127.0.0.1:1939`)
|
|
7
|
+
* no longer matches the hub's current issuer. The hub rejects it on every CLI
|
|
8
|
+
* auth flow. The self-heal re-issues the hub's OWN credential under the new
|
|
9
|
+
* issuer, preserving scope-set + sub, gated on the token's signature verifying
|
|
10
|
+
* against this hub's current keys (no privilege-escalation surface).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the existing `operator-token.test.ts` harness shape.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
20
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
21
|
+
import {
|
|
22
|
+
OPERATOR_TOKEN_AUDIENCE,
|
|
23
|
+
OPERATOR_TOKEN_CLIENT_ID,
|
|
24
|
+
OPERATOR_TOKEN_FILENAME,
|
|
25
|
+
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
26
|
+
issueOperatorToken,
|
|
27
|
+
operatorTokenPath,
|
|
28
|
+
readOperatorTokenFile,
|
|
29
|
+
selfHealOperatorTokenIssuer,
|
|
30
|
+
writeOperatorTokenFile,
|
|
31
|
+
} from "../operator-token.ts";
|
|
32
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
33
|
+
|
|
34
|
+
interface Harness {
|
|
35
|
+
dir: string;
|
|
36
|
+
cleanup: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeHarness(): Harness {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-op-heal-"));
|
|
41
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LOOPBACK_ISSUER = "http://127.0.0.1:1939";
|
|
45
|
+
const PUBLIC_ISSUER = "https://gitcoin-parachute.unforced.dev";
|
|
46
|
+
|
|
47
|
+
describe("selfHealOperatorTokenIssuer", () => {
|
|
48
|
+
test("stale-iss genuine token + non-loopback new issuer → rotated, scope-set preserved, valid under new issuer", async () => {
|
|
49
|
+
const h = makeHarness();
|
|
50
|
+
try {
|
|
51
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
52
|
+
try {
|
|
53
|
+
rotateSigningKey(db);
|
|
54
|
+
// Mint at loopback issuer with a non-default scope-set ("start").
|
|
55
|
+
await issueOperatorToken(db, "user-abc", {
|
|
56
|
+
dir: h.dir,
|
|
57
|
+
issuer: LOOPBACK_ISSUER,
|
|
58
|
+
scopeSet: "start",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
62
|
+
issuer: PUBLIC_ISSUER,
|
|
63
|
+
configDir: h.dir,
|
|
64
|
+
});
|
|
65
|
+
expect(status.kind).toBe("rotated");
|
|
66
|
+
if (status.kind === "rotated") {
|
|
67
|
+
expect(status.scopeSet).toBe("start");
|
|
68
|
+
expect(status.path).toBe(operatorTokenPath(h.dir));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The on-disk token now has iss=PUBLIC_ISSUER, scope-set preserved,
|
|
72
|
+
// and validates under the new issuer.
|
|
73
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
74
|
+
expect(onDisk).not.toBeNull();
|
|
75
|
+
const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
|
|
76
|
+
expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
|
|
77
|
+
expect(validated.payload.sub).toBe("user-abc");
|
|
78
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
79
|
+
} finally {
|
|
80
|
+
db.close();
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
h.cleanup();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("iss already current → fresh, on-disk file byte-identical", async () => {
|
|
88
|
+
const h = makeHarness();
|
|
89
|
+
try {
|
|
90
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
91
|
+
try {
|
|
92
|
+
rotateSigningKey(db);
|
|
93
|
+
await issueOperatorToken(db, "user-abc", {
|
|
94
|
+
dir: h.dir,
|
|
95
|
+
issuer: PUBLIC_ISSUER,
|
|
96
|
+
scopeSet: "admin",
|
|
97
|
+
});
|
|
98
|
+
const before = await readFile(operatorTokenPath(h.dir));
|
|
99
|
+
|
|
100
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
101
|
+
issuer: PUBLIC_ISSUER,
|
|
102
|
+
configDir: h.dir,
|
|
103
|
+
});
|
|
104
|
+
expect(status.kind).toBe("fresh");
|
|
105
|
+
|
|
106
|
+
const after = await readFile(operatorTokenPath(h.dir));
|
|
107
|
+
expect(after.equals(before)).toBe(true);
|
|
108
|
+
} finally {
|
|
109
|
+
db.close();
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
h.cleanup();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("absent token file → absent, no throw", async () => {
|
|
117
|
+
const h = makeHarness();
|
|
118
|
+
try {
|
|
119
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
120
|
+
try {
|
|
121
|
+
rotateSigningKey(db);
|
|
122
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
123
|
+
issuer: PUBLIC_ISSUER,
|
|
124
|
+
configDir: h.dir,
|
|
125
|
+
});
|
|
126
|
+
expect(status.kind).toBe("absent");
|
|
127
|
+
} finally {
|
|
128
|
+
db.close();
|
|
129
|
+
}
|
|
130
|
+
} finally {
|
|
131
|
+
h.cleanup();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("bad signature (corrupt token) → skipped:unverifiable, disk untouched", async () => {
|
|
136
|
+
const h = makeHarness();
|
|
137
|
+
try {
|
|
138
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
139
|
+
try {
|
|
140
|
+
rotateSigningKey(db);
|
|
141
|
+
// Mint a real token at loopback, then corrupt its signature segment so
|
|
142
|
+
// it no longer verifies against the hub's keys.
|
|
143
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
144
|
+
dir: h.dir,
|
|
145
|
+
issuer: LOOPBACK_ISSUER,
|
|
146
|
+
scopeSet: "start",
|
|
147
|
+
});
|
|
148
|
+
const parts = issued.token.split(".");
|
|
149
|
+
const hdr = parts[0] ?? "";
|
|
150
|
+
const body = parts[1] ?? "";
|
|
151
|
+
const sig = parts[2] ?? "";
|
|
152
|
+
// Flip a character in the MIDDLE of the signature, keeping it
|
|
153
|
+
// base64url-shaped. Corrupting the last char is unreliable: the final
|
|
154
|
+
// base64url char of an RS256 signature encodes only padding bits, so a
|
|
155
|
+
// flip there can decode to identical bytes and leave the signature
|
|
156
|
+
// valid (~25% of mints). A mid-signature char sits in a full 4-char
|
|
157
|
+
// group with no padding, so any single-char change deterministically
|
|
158
|
+
// alters the decoded bytes and invalidates the signature.
|
|
159
|
+
const mid = Math.floor(sig.length / 2);
|
|
160
|
+
const flipped = sig[mid] === "A" ? "B" : "A";
|
|
161
|
+
const tampered = `${hdr}.${body}.${sig.slice(0, mid)}${flipped}${sig.slice(mid + 1)}`;
|
|
162
|
+
await writeOperatorTokenFile(tampered, h.dir);
|
|
163
|
+
|
|
164
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
165
|
+
issuer: PUBLIC_ISSUER,
|
|
166
|
+
configDir: h.dir,
|
|
167
|
+
});
|
|
168
|
+
expect(status.kind).toBe("skipped");
|
|
169
|
+
if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
|
|
170
|
+
|
|
171
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
172
|
+
expect(onDisk).toBe(tampered);
|
|
173
|
+
} finally {
|
|
174
|
+
db.close();
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
h.cleanup();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("expired token (exp in the past) → skipped:unverifiable (jose throws), disk untouched", async () => {
|
|
182
|
+
const h = makeHarness();
|
|
183
|
+
try {
|
|
184
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
185
|
+
try {
|
|
186
|
+
rotateSigningKey(db);
|
|
187
|
+
// Mint a token that expired in the past — jose's exp check throws on
|
|
188
|
+
// validate, so the self-heal must classify it unverifiable.
|
|
189
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
190
|
+
dir: h.dir,
|
|
191
|
+
issuer: LOOPBACK_ISSUER,
|
|
192
|
+
scopeSet: "start",
|
|
193
|
+
ttlSeconds: 60,
|
|
194
|
+
now: () => new Date("2026-01-01T00:00:00Z"),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
198
|
+
issuer: PUBLIC_ISSUER,
|
|
199
|
+
configDir: h.dir,
|
|
200
|
+
});
|
|
201
|
+
expect(status.kind).toBe("skipped");
|
|
202
|
+
if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
|
|
203
|
+
|
|
204
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
205
|
+
expect(onDisk).toBe(issued.token);
|
|
206
|
+
} finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
h.cleanup();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("aud != operator (aud=scribe, valid sig, stale iss) → skipped:aud-mismatch, untouched", async () => {
|
|
215
|
+
const h = makeHarness();
|
|
216
|
+
try {
|
|
217
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
218
|
+
try {
|
|
219
|
+
rotateSigningKey(db);
|
|
220
|
+
// A hub-signed token with the WRONG audience must not be re-minted as
|
|
221
|
+
// an operator token (privilege guard).
|
|
222
|
+
const signed = await signAccessToken(db, {
|
|
223
|
+
sub: "user-abc",
|
|
224
|
+
scopes: ["scribe:transcribe"],
|
|
225
|
+
audience: "scribe",
|
|
226
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
227
|
+
issuer: LOOPBACK_ISSUER,
|
|
228
|
+
extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "admin" },
|
|
229
|
+
});
|
|
230
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
231
|
+
|
|
232
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
233
|
+
issuer: PUBLIC_ISSUER,
|
|
234
|
+
configDir: h.dir,
|
|
235
|
+
});
|
|
236
|
+
expect(status.kind).toBe("skipped");
|
|
237
|
+
if (status.kind === "skipped") expect(status.reason).toBe("aud-mismatch");
|
|
238
|
+
|
|
239
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
240
|
+
expect(onDisk).toBe(signed.token);
|
|
241
|
+
} finally {
|
|
242
|
+
db.close();
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
h.cleanup();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("missing/invalid pa_scope_set (stale iss, aud=operator) → skipped:no-scope-set, NOT widened to admin", async () => {
|
|
250
|
+
const h = makeHarness();
|
|
251
|
+
try {
|
|
252
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
253
|
+
try {
|
|
254
|
+
rotateSigningKey(db);
|
|
255
|
+
// aud=operator + stale iss + NO pa_scope_set claim. Falling back to a
|
|
256
|
+
// default scope-set would silently widen to admin (hub#224); refuse.
|
|
257
|
+
const signed = await signAccessToken(db, {
|
|
258
|
+
sub: "user-abc",
|
|
259
|
+
scopes: ["scribe:transcribe"],
|
|
260
|
+
audience: OPERATOR_TOKEN_AUDIENCE,
|
|
261
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
262
|
+
issuer: LOOPBACK_ISSUER,
|
|
263
|
+
});
|
|
264
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
265
|
+
|
|
266
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
267
|
+
issuer: PUBLIC_ISSUER,
|
|
268
|
+
configDir: h.dir,
|
|
269
|
+
});
|
|
270
|
+
expect(status.kind).toBe("skipped");
|
|
271
|
+
if (status.kind === "skipped") expect(status.reason).toBe("no-scope-set");
|
|
272
|
+
|
|
273
|
+
// On-disk file unchanged — no widening occurred.
|
|
274
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
275
|
+
expect(onDisk).toBe(signed.token);
|
|
276
|
+
} finally {
|
|
277
|
+
db.close();
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
h.cleanup();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("missing sub (stale iss, aud=operator, valid scope-set) → skipped:no-sub, untouched", async () => {
|
|
285
|
+
const h = makeHarness();
|
|
286
|
+
try {
|
|
287
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
288
|
+
try {
|
|
289
|
+
rotateSigningKey(db);
|
|
290
|
+
// aud=operator + recognized scope-set + stale iss but NO sub — we can't
|
|
291
|
+
// re-mint a token we can't attribute.
|
|
292
|
+
const signed = await signAccessToken(db, {
|
|
293
|
+
sub: "",
|
|
294
|
+
scopes: ["parachute:host:start"],
|
|
295
|
+
audience: OPERATOR_TOKEN_AUDIENCE,
|
|
296
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
297
|
+
issuer: LOOPBACK_ISSUER,
|
|
298
|
+
extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "start" },
|
|
299
|
+
});
|
|
300
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
301
|
+
|
|
302
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
303
|
+
issuer: PUBLIC_ISSUER,
|
|
304
|
+
configDir: h.dir,
|
|
305
|
+
});
|
|
306
|
+
expect(status.kind).toBe("skipped");
|
|
307
|
+
if (status.kind === "skipped") expect(status.reason).toBe("no-sub");
|
|
308
|
+
|
|
309
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
310
|
+
expect(onDisk).toBe(signed.token);
|
|
311
|
+
} finally {
|
|
312
|
+
db.close();
|
|
313
|
+
}
|
|
314
|
+
} finally {
|
|
315
|
+
h.cleanup();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("target issuer loopback (public token on disk) → skipped:issuer-loopback, public token preserved", async () => {
|
|
320
|
+
const h = makeHarness();
|
|
321
|
+
try {
|
|
322
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
323
|
+
try {
|
|
324
|
+
rotateSigningKey(db);
|
|
325
|
+
// A good PUBLIC-issuer token; calling self-heal with a loopback target
|
|
326
|
+
// must never downgrade it.
|
|
327
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
328
|
+
dir: h.dir,
|
|
329
|
+
issuer: PUBLIC_ISSUER,
|
|
330
|
+
scopeSet: "admin",
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
334
|
+
issuer: LOOPBACK_ISSUER,
|
|
335
|
+
configDir: h.dir,
|
|
336
|
+
});
|
|
337
|
+
expect(status.kind).toBe("skipped");
|
|
338
|
+
if (status.kind === "skipped") expect(status.reason).toBe("issuer-loopback");
|
|
339
|
+
|
|
340
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
341
|
+
expect(onDisk).toBe(issued.token);
|
|
342
|
+
// Still a public-issuer token.
|
|
343
|
+
const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
|
|
344
|
+
expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
|
|
345
|
+
} finally {
|
|
346
|
+
db.close();
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
h.cleanup();
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("scope-set preserved verbatim — 'auth' set stays 'auth', not widened to admin", async () => {
|
|
354
|
+
const h = makeHarness();
|
|
355
|
+
try {
|
|
356
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
357
|
+
try {
|
|
358
|
+
rotateSigningKey(db);
|
|
359
|
+
await issueOperatorToken(db, "user-xyz", {
|
|
360
|
+
dir: h.dir,
|
|
361
|
+
issuer: LOOPBACK_ISSUER,
|
|
362
|
+
scopeSet: "auth",
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
366
|
+
issuer: PUBLIC_ISSUER,
|
|
367
|
+
configDir: h.dir,
|
|
368
|
+
});
|
|
369
|
+
expect(status.kind).toBe("rotated");
|
|
370
|
+
if (status.kind === "rotated") expect(status.scopeSet).toBe("auth");
|
|
371
|
+
|
|
372
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
373
|
+
const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
|
|
374
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("auth");
|
|
375
|
+
// The minted scopes are the "auth" set, NOT the admin superset.
|
|
376
|
+
expect(validated.payload.scope).toBe("parachute:host:auth");
|
|
377
|
+
} finally {
|
|
378
|
+
db.close();
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
h.cleanup();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("file path is the canonical operator.token under configDir", async () => {
|
|
386
|
+
const h = makeHarness();
|
|
387
|
+
try {
|
|
388
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
389
|
+
try {
|
|
390
|
+
rotateSigningKey(db);
|
|
391
|
+
await issueOperatorToken(db, "user-abc", {
|
|
392
|
+
dir: h.dir,
|
|
393
|
+
issuer: LOOPBACK_ISSUER,
|
|
394
|
+
scopeSet: "start",
|
|
395
|
+
});
|
|
396
|
+
const status = await selfHealOperatorTokenIssuer(db, {
|
|
397
|
+
issuer: PUBLIC_ISSUER,
|
|
398
|
+
configDir: h.dir,
|
|
399
|
+
});
|
|
400
|
+
if (status.kind === "rotated") {
|
|
401
|
+
expect(status.path).toBe(join(h.dir, OPERATOR_TOKEN_FILENAME));
|
|
402
|
+
} else {
|
|
403
|
+
throw new Error(`expected rotated, got ${status.kind}`);
|
|
404
|
+
}
|
|
405
|
+
} finally {
|
|
406
|
+
db.close();
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
h.cleanup();
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_MODULES_URL,
|
|
4
|
+
ERROR_TYPE_PERSISTENT,
|
|
5
|
+
ERROR_TYPE_TRANSIENT,
|
|
6
|
+
TRANSIENT_MAX_ATTEMPTS,
|
|
7
|
+
TRANSIENT_RETRY_MS,
|
|
8
|
+
renderProxyError,
|
|
9
|
+
renderProxyErrorHtml,
|
|
10
|
+
renderProxyErrorJson,
|
|
11
|
+
statusForState,
|
|
12
|
+
toResponse,
|
|
13
|
+
wantsHtml,
|
|
14
|
+
} from "../proxy-error-ui.ts";
|
|
15
|
+
|
|
16
|
+
function req(accept?: string): Request {
|
|
17
|
+
const headers: Record<string, string> = {};
|
|
18
|
+
if (accept !== undefined) headers.accept = accept;
|
|
19
|
+
return new Request("http://127.0.0.1/vault/default/health", { headers });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("wantsHtml — Accept-header negotiation", () => {
|
|
23
|
+
test("text/html → HTML", () => {
|
|
24
|
+
expect(wantsHtml(req("text/html"))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
test("application/json → JSON", () => {
|
|
27
|
+
expect(wantsHtml(req("application/json"))).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
test("missing Accept → HTML (browser-ish default)", () => {
|
|
30
|
+
expect(wantsHtml(req())).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
test("*/* alone → HTML", () => {
|
|
33
|
+
expect(wantsHtml(req("*/*"))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
test("text/html,application/xhtml+xml,application/xml → HTML", () => {
|
|
36
|
+
expect(wantsHtml(req("text/html,application/xhtml+xml,application/xml"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
test("application/json with text/html present → HTML", () => {
|
|
39
|
+
// If a client sends both, we lean HTML — matches the hub's existing
|
|
40
|
+
// one-line accept check at the 404 fallthrough.
|
|
41
|
+
expect(wantsHtml(req("application/json, text/html"))).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("statusForState", () => {
|
|
46
|
+
test("transient → 503", () => {
|
|
47
|
+
expect(statusForState("transient")).toBe(503);
|
|
48
|
+
});
|
|
49
|
+
test("persistent → 502", () => {
|
|
50
|
+
expect(statusForState("persistent")).toBe(502);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("renderProxyErrorJson", () => {
|
|
55
|
+
test("transient → 503 JSON with retry_after_ms + max_attempts + no admin_url", () => {
|
|
56
|
+
const out = renderProxyErrorJson({
|
|
57
|
+
short: "vault",
|
|
58
|
+
serviceLabel: "parachute-vault",
|
|
59
|
+
state: "transient",
|
|
60
|
+
upstreamError: "ECONNREFUSED",
|
|
61
|
+
});
|
|
62
|
+
expect(out.status).toBe(503);
|
|
63
|
+
expect(out.contentType).toBe("application/json");
|
|
64
|
+
expect(out.retryAfter).toBe("2");
|
|
65
|
+
const body = JSON.parse(out.body) as {
|
|
66
|
+
error: string;
|
|
67
|
+
error_type: string;
|
|
68
|
+
retry_after_ms: number;
|
|
69
|
+
max_attempts: number;
|
|
70
|
+
admin_url?: string;
|
|
71
|
+
service: string;
|
|
72
|
+
};
|
|
73
|
+
expect(body.error).toBe(ERROR_TYPE_TRANSIENT);
|
|
74
|
+
expect(body.error_type).toBe(ERROR_TYPE_TRANSIENT);
|
|
75
|
+
expect(body.retry_after_ms).toBe(TRANSIENT_RETRY_MS);
|
|
76
|
+
expect(body.max_attempts).toBe(TRANSIENT_MAX_ATTEMPTS);
|
|
77
|
+
expect(body.admin_url).toBeUndefined();
|
|
78
|
+
expect(body.service).toBe("vault");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("persistent → 502 JSON with admin_url + no retry_after_ms", () => {
|
|
82
|
+
const out = renderProxyErrorJson({
|
|
83
|
+
short: "scribe",
|
|
84
|
+
serviceLabel: "scribe",
|
|
85
|
+
state: "persistent",
|
|
86
|
+
upstreamError: "ECONNREFUSED",
|
|
87
|
+
});
|
|
88
|
+
expect(out.status).toBe(502);
|
|
89
|
+
expect(out.contentType).toBe("application/json");
|
|
90
|
+
expect(out.retryAfter).toBeUndefined();
|
|
91
|
+
const body = JSON.parse(out.body) as {
|
|
92
|
+
error: string;
|
|
93
|
+
error_type: string;
|
|
94
|
+
retry_after_ms?: number;
|
|
95
|
+
admin_url?: string;
|
|
96
|
+
};
|
|
97
|
+
expect(body.error).toBe(ERROR_TYPE_PERSISTENT);
|
|
98
|
+
expect(body.error_type).toBe(ERROR_TYPE_PERSISTENT);
|
|
99
|
+
expect(body.admin_url).toBe(ADMIN_MODULES_URL);
|
|
100
|
+
expect(body.retry_after_ms).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("persistent JSON folds upstreamError into error_description", () => {
|
|
104
|
+
const out = renderProxyErrorJson({
|
|
105
|
+
short: "vault",
|
|
106
|
+
serviceLabel: "parachute-vault",
|
|
107
|
+
state: "persistent",
|
|
108
|
+
upstreamError: "ECONNREFUSED 127.0.0.1:1940",
|
|
109
|
+
});
|
|
110
|
+
const body = JSON.parse(out.body) as { error_description: string };
|
|
111
|
+
expect(body.error_description).toContain("ECONNREFUSED");
|
|
112
|
+
expect(body.error_description).toContain("parachute-vault");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("renderProxyErrorHtml", () => {
|
|
117
|
+
test("transient HTML → 503 + meta-refresh + Retry-After + poll script", () => {
|
|
118
|
+
const out = renderProxyErrorHtml({
|
|
119
|
+
short: "vault",
|
|
120
|
+
serviceLabel: "parachute-vault",
|
|
121
|
+
state: "transient",
|
|
122
|
+
upstreamError: "ECONNREFUSED",
|
|
123
|
+
});
|
|
124
|
+
expect(out.status).toBe(503);
|
|
125
|
+
expect(out.contentType).toBe("text/html; charset=utf-8");
|
|
126
|
+
expect(out.retryAfter).toBe("2");
|
|
127
|
+
expect(out.body).toContain(`<meta http-equiv="refresh" content="2">`);
|
|
128
|
+
expect(out.body).toContain("Just a moment");
|
|
129
|
+
expect(out.body).toContain("/api/ready");
|
|
130
|
+
expect(out.body).toContain("ready_modules");
|
|
131
|
+
expect(out.body).toContain(`maxAttempts = ${TRANSIENT_MAX_ATTEMPTS}`);
|
|
132
|
+
// Transient page MUST NOT include an admin link (Aaron design (d)).
|
|
133
|
+
expect(out.body).not.toContain("/admin/modules");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("persistent HTML → 502 + no meta-refresh + admin link + manual refresh", () => {
|
|
137
|
+
const out = renderProxyErrorHtml({
|
|
138
|
+
short: "vault",
|
|
139
|
+
serviceLabel: "parachute-vault",
|
|
140
|
+
state: "persistent",
|
|
141
|
+
upstreamError: "ECONNREFUSED",
|
|
142
|
+
});
|
|
143
|
+
expect(out.status).toBe(502);
|
|
144
|
+
expect(out.contentType).toBe("text/html; charset=utf-8");
|
|
145
|
+
expect(out.retryAfter).toBeUndefined();
|
|
146
|
+
expect(out.body).not.toContain(`http-equiv="refresh"`);
|
|
147
|
+
expect(out.body).toContain("Module unreachable");
|
|
148
|
+
expect(out.body).toContain("/admin/modules");
|
|
149
|
+
expect(out.body).toContain("View module status");
|
|
150
|
+
// No periodic poll on the persistent page — only the manual-refresh
|
|
151
|
+
// listener. Assert by checking that maxAttempts/intervalMs constants
|
|
152
|
+
// aren't emitted into the script.
|
|
153
|
+
expect(out.body).not.toContain("maxAttempts");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("HTML escapes the service short name into the body", () => {
|
|
157
|
+
// Constructing a malicious short shouldn't break the page. ServiceEntry
|
|
158
|
+
// names are validated upstream but the renderer is defense-in-depth.
|
|
159
|
+
const out = renderProxyErrorHtml({
|
|
160
|
+
short: "<script>alert(1)</script>",
|
|
161
|
+
serviceLabel: "<malicious>",
|
|
162
|
+
state: "persistent",
|
|
163
|
+
upstreamError: "x",
|
|
164
|
+
});
|
|
165
|
+
expect(out.body).not.toContain("<script>alert(1)</script>");
|
|
166
|
+
expect(out.body).toContain("<script>");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("renderProxyError — Accept-driven dispatch", () => {
|
|
171
|
+
test("JSON-accepting request → JSON renderer", () => {
|
|
172
|
+
const out = renderProxyError(req("application/json"), {
|
|
173
|
+
short: "vault",
|
|
174
|
+
serviceLabel: "parachute-vault",
|
|
175
|
+
state: "transient",
|
|
176
|
+
upstreamError: "x",
|
|
177
|
+
});
|
|
178
|
+
expect(out.contentType).toBe("application/json");
|
|
179
|
+
});
|
|
180
|
+
test("HTML-accepting request → HTML renderer", () => {
|
|
181
|
+
const out = renderProxyError(req("text/html"), {
|
|
182
|
+
short: "vault",
|
|
183
|
+
serviceLabel: "parachute-vault",
|
|
184
|
+
state: "transient",
|
|
185
|
+
upstreamError: "x",
|
|
186
|
+
});
|
|
187
|
+
expect(out.contentType).toBe("text/html; charset=utf-8");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("toResponse", () => {
|
|
192
|
+
test("attaches Retry-After when present", () => {
|
|
193
|
+
const out = toResponse({
|
|
194
|
+
body: "{}",
|
|
195
|
+
status: 503,
|
|
196
|
+
contentType: "application/json",
|
|
197
|
+
retryAfter: "2",
|
|
198
|
+
});
|
|
199
|
+
expect(out.status).toBe(503);
|
|
200
|
+
expect(out.headers.get("retry-after")).toBe("2");
|
|
201
|
+
expect(out.headers.get("content-type")).toBe("application/json");
|
|
202
|
+
expect(out.headers.get("cache-control")).toBe("no-store");
|
|
203
|
+
});
|
|
204
|
+
test("omits Retry-After when absent", () => {
|
|
205
|
+
const out = toResponse({
|
|
206
|
+
body: "{}",
|
|
207
|
+
status: 502,
|
|
208
|
+
contentType: "application/json",
|
|
209
|
+
});
|
|
210
|
+
expect(out.headers.get("retry-after")).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
});
|