@openparachute/hub 0.5.14-rc.15 → 0.5.14-rc.17
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__/account-home-ui.test.ts +134 -0
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/expose-cloudflare.test.ts +116 -0
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/lifecycle.test.ts +45 -0
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +137 -0
- package/src/account-home-ui.ts +253 -11
- package/src/account-vault-token.ts +282 -0
- package/src/api-account.ts +12 -0
- package/src/commands/expose-cloudflare.ts +49 -1
- package/src/commands/lifecycle.ts +29 -15
- package/src/hub-server.ts +22 -0
- package/src/rate-limit.ts +29 -0
- package/src/users.ts +58 -0
- package/src/vault-hub-origin-env.ts +63 -0
package/package.json
CHANGED
|
@@ -262,4 +262,138 @@ describe("renderAccountHome", () => {
|
|
|
262
262
|
// The escaped vault name also flows into the connect command + endpoint.
|
|
263
263
|
expect(html).toContain("parachute-<vault>");
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
// --- friend vault-token mint affordance (the new surface) ----------------
|
|
267
|
+
|
|
268
|
+
test("mint affordance — read+write tile offers both verbs, POSTs to the right path", () => {
|
|
269
|
+
const html = renderAccountHome({
|
|
270
|
+
username: "alice",
|
|
271
|
+
assignedVaults: ["work"],
|
|
272
|
+
passwordChanged: true,
|
|
273
|
+
hubOrigin: HUB_ORIGIN,
|
|
274
|
+
isFirstAdmin: false,
|
|
275
|
+
csrfToken: CSRF,
|
|
276
|
+
twoFactorEnabled: false,
|
|
277
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
278
|
+
});
|
|
279
|
+
// The collapsible mint block is present, framed as secondary (headless).
|
|
280
|
+
expect(html).toContain('data-testid="token-mint"');
|
|
281
|
+
expect(html).toContain("Mint an access token");
|
|
282
|
+
expect(html).toContain("for scripts / headless clients");
|
|
283
|
+
// Both verb radios render.
|
|
284
|
+
expect(html).toContain('data-testid="mint-verb-read"');
|
|
285
|
+
expect(html).toContain('data-testid="mint-verb-write"');
|
|
286
|
+
// Form POSTs to the per-vault endpoint with the CSRF token embedded.
|
|
287
|
+
expect(html).toContain('action="/account/vault-token/work"');
|
|
288
|
+
expect(html).toContain('method="POST"');
|
|
289
|
+
expect(html).toContain('data-testid="mint-form"');
|
|
290
|
+
expect(html).toContain(CSRF);
|
|
291
|
+
// Recommends the no-token path as default.
|
|
292
|
+
expect(html).toContain("no-token");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("mint affordance — a read-only role offers ONLY the read verb", () => {
|
|
296
|
+
// Today every assignment is write-role, but the renderer is verb-blind to
|
|
297
|
+
// the role: it shows exactly the verbs it's handed. A read-only cap must
|
|
298
|
+
// never surface a write radio (the server would reject it anyway).
|
|
299
|
+
const html = renderAccountHome({
|
|
300
|
+
username: "alice",
|
|
301
|
+
assignedVaults: ["work"],
|
|
302
|
+
passwordChanged: true,
|
|
303
|
+
hubOrigin: HUB_ORIGIN,
|
|
304
|
+
isFirstAdmin: false,
|
|
305
|
+
csrfToken: CSRF,
|
|
306
|
+
twoFactorEnabled: false,
|
|
307
|
+
mintableVerbs: { work: ["read"] },
|
|
308
|
+
});
|
|
309
|
+
expect(html).toContain('data-testid="mint-verb-read"');
|
|
310
|
+
expect(html).not.toContain('data-testid="mint-verb-write"');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("mint affordance — never offers an admin verb", () => {
|
|
314
|
+
const html = renderAccountHome({
|
|
315
|
+
username: "alice",
|
|
316
|
+
assignedVaults: ["work"],
|
|
317
|
+
passwordChanged: true,
|
|
318
|
+
hubOrigin: HUB_ORIGIN,
|
|
319
|
+
isFirstAdmin: false,
|
|
320
|
+
csrfToken: CSRF,
|
|
321
|
+
twoFactorEnabled: false,
|
|
322
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
323
|
+
});
|
|
324
|
+
expect(html).not.toContain('value="admin"');
|
|
325
|
+
expect(html).not.toContain('data-testid="mint-verb-admin"');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
|
|
329
|
+
// Admin branch: no tiles at all, so no mint block.
|
|
330
|
+
const admin = renderAccountHome({
|
|
331
|
+
username: "admin",
|
|
332
|
+
assignedVaults: [],
|
|
333
|
+
passwordChanged: true,
|
|
334
|
+
hubOrigin: HUB_ORIGIN,
|
|
335
|
+
isFirstAdmin: true,
|
|
336
|
+
csrfToken: CSRF,
|
|
337
|
+
twoFactorEnabled: false,
|
|
338
|
+
});
|
|
339
|
+
expect(admin).not.toContain('data-testid="token-mint"');
|
|
340
|
+
// Assigned vault but empty verb list (fail-closed unknown role) → no block.
|
|
341
|
+
const empty = renderAccountHome({
|
|
342
|
+
username: "alice",
|
|
343
|
+
assignedVaults: ["work"],
|
|
344
|
+
passwordChanged: true,
|
|
345
|
+
hubOrigin: HUB_ORIGIN,
|
|
346
|
+
isFirstAdmin: false,
|
|
347
|
+
csrfToken: CSRF,
|
|
348
|
+
twoFactorEnabled: false,
|
|
349
|
+
mintableVerbs: { work: [] },
|
|
350
|
+
});
|
|
351
|
+
expect(empty).not.toContain('data-testid="token-mint"');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("minted-token banner — shows the token once with a save-it warning, no revoke claim", () => {
|
|
355
|
+
const html = renderAccountHome({
|
|
356
|
+
username: "alice",
|
|
357
|
+
assignedVaults: ["work"],
|
|
358
|
+
passwordChanged: true,
|
|
359
|
+
hubOrigin: HUB_ORIGIN,
|
|
360
|
+
isFirstAdmin: false,
|
|
361
|
+
csrfToken: CSRF,
|
|
362
|
+
twoFactorEnabled: false,
|
|
363
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
364
|
+
mintedToken: {
|
|
365
|
+
vaultName: "work",
|
|
366
|
+
verb: "read",
|
|
367
|
+
token: "eyJhbGciOi.FAKE.TOKEN",
|
|
368
|
+
expiresInDays: 90,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
expect(html).toContain('data-testid="minted-token-banner"');
|
|
372
|
+
expect(html).toContain("eyJhbGciOi.FAKE.TOKEN");
|
|
373
|
+
expect(html).toContain('data-testid="copy-minted-token"');
|
|
374
|
+
// Explicit "won't be shown again" + the scope + the TTL.
|
|
375
|
+
expect(html).toContain("won't be shown again");
|
|
376
|
+
expect(html).toContain("vault:work:read");
|
|
377
|
+
expect(html).toContain("90 days");
|
|
378
|
+
// No false revoke-yourself promise (no friend-facing revoke today).
|
|
379
|
+
expect(html).toContain("ask the hub operator");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("mint error banner — surfaces an inline authorization error", () => {
|
|
383
|
+
const html = renderAccountHome({
|
|
384
|
+
username: "alice",
|
|
385
|
+
assignedVaults: ["work"],
|
|
386
|
+
passwordChanged: true,
|
|
387
|
+
hubOrigin: HUB_ORIGIN,
|
|
388
|
+
isFirstAdmin: false,
|
|
389
|
+
csrfToken: CSRF,
|
|
390
|
+
twoFactorEnabled: false,
|
|
391
|
+
mintableVerbs: { work: ["read", "write"] },
|
|
392
|
+
mintError: 'You\'re not assigned to a vault named "other".',
|
|
393
|
+
});
|
|
394
|
+
expect(html).toContain('data-testid="mint-error-banner"');
|
|
395
|
+
expect(html).toContain("not assigned");
|
|
396
|
+
// Error render must NOT also show a token.
|
|
397
|
+
expect(html).not.toContain('data-testid="minted-token-banner"');
|
|
398
|
+
});
|
|
265
399
|
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security tests for the friend-facing scoped vault token mint —
|
|
3
|
+
* `POST /account/vault-token/<name>` (`handleAccountVaultTokenPost`).
|
|
4
|
+
*
|
|
5
|
+
* This is a new auth-mint surface, so the authorization is tested
|
|
6
|
+
* adversarially. The spine:
|
|
7
|
+
* - No session → 401 (no mint).
|
|
8
|
+
* - Assigned vault → 200, token carries `vault:<name>:<verb>`,
|
|
9
|
+
* `aud=vault.<name>`, `iss=<hub>`, sub=user.
|
|
10
|
+
* - UNassigned vault → 403 (cannot mint for a vault not in the
|
|
11
|
+
* user's `user_vaults` assignment — blocks
|
|
12
|
+
* cross-vault).
|
|
13
|
+
* - `admin` verb → rejected (not in the form vocabulary).
|
|
14
|
+
* - Broader/garbage verb → rejected.
|
|
15
|
+
* - First admin → 403 (no `user_vaults` rows → unrestricted
|
|
16
|
+
* admins use the SPA path, not this one).
|
|
17
|
+
* - CSRF missing/mismatch → 400.
|
|
18
|
+
* - Rate limit → 429 after the bucket fills.
|
|
19
|
+
* - The minted token is a valid hub JWT the vault would accept.
|
|
20
|
+
*/
|
|
21
|
+
import type { Database } from "bun:sqlite";
|
|
22
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { ACCOUNT_VAULT_TOKEN_TTL_SECONDS } from "../account-home-ui.ts";
|
|
27
|
+
import { handleAccountVaultTokenPost } from "../account-vault-token.ts";
|
|
28
|
+
import { CSRF_FIELD_NAME, buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
29
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
30
|
+
import { validateAccessToken } from "../jwt-sign.ts";
|
|
31
|
+
import { __resetForTests } from "../rate-limit.ts";
|
|
32
|
+
import { SESSION_TTL_MS, buildSessionCookie, createSession } from "../sessions.ts";
|
|
33
|
+
import { createUser } from "../users.ts";
|
|
34
|
+
|
|
35
|
+
const ISSUER = "https://hub.test";
|
|
36
|
+
|
|
37
|
+
interface Harness {
|
|
38
|
+
db: Database;
|
|
39
|
+
cleanup: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeHarness(): Harness {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-account-vault-token-"));
|
|
44
|
+
const db = openHubDb(hubDbPath(dir));
|
|
45
|
+
return {
|
|
46
|
+
db,
|
|
47
|
+
cleanup: () => {
|
|
48
|
+
db.close();
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let harness: Harness;
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
harness = makeHarness();
|
|
57
|
+
__resetForTests();
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
harness.cleanup();
|
|
61
|
+
__resetForTests();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const deps = () => ({ db: harness.db, hubOrigin: ISSUER });
|
|
65
|
+
|
|
66
|
+
/** A shared CSRF token + matching cookie value for the double-submit handshake. */
|
|
67
|
+
function csrfPair(): { token: string; cookieFragment: string } {
|
|
68
|
+
const token = generateCsrfToken();
|
|
69
|
+
// buildCsrfCookie(...) → "parachute_hub_csrf=<token>; HttpOnly; ...". We only
|
|
70
|
+
// need the name=value fragment to join with the session cookie.
|
|
71
|
+
const cookie = buildCsrfCookie(token, { secure: false }).split(";")[0] ?? "";
|
|
72
|
+
return { token, cookieFragment: cookie };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Build the first-admin operator + a friend assigned to `vaults`. */
|
|
76
|
+
async function seedFriend(
|
|
77
|
+
vaults: string[],
|
|
78
|
+
): Promise<{ friendId: string; cookie: string; csrfToken: string }> {
|
|
79
|
+
await createUser(harness.db, "operator", "operator-password-123");
|
|
80
|
+
const friend = await createUser(harness.db, "friend", "friend-password-123", {
|
|
81
|
+
assignedVaults: vaults,
|
|
82
|
+
allowMulti: true,
|
|
83
|
+
});
|
|
84
|
+
const session = createSession(harness.db, { userId: friend.id });
|
|
85
|
+
const { token, cookieFragment } = csrfPair();
|
|
86
|
+
const sessionCookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000));
|
|
87
|
+
const cookie = `${sessionCookie}; ${cookieFragment}`;
|
|
88
|
+
return { friendId: friend.id, cookie, csrfToken: token };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mintReq(
|
|
92
|
+
vaultName: string,
|
|
93
|
+
opts: { cookie?: string; csrfToken?: string; verb?: string; omitCsrf?: boolean } = {},
|
|
94
|
+
): Request {
|
|
95
|
+
const body = new URLSearchParams();
|
|
96
|
+
if (!opts.omitCsrf && opts.csrfToken !== undefined) body.set(CSRF_FIELD_NAME, opts.csrfToken);
|
|
97
|
+
if (opts.verb !== undefined) body.set("verb", opts.verb);
|
|
98
|
+
const headers: Record<string, string> = {
|
|
99
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
100
|
+
};
|
|
101
|
+
if (opts.cookie) headers.cookie = opts.cookie;
|
|
102
|
+
return new Request(`${ISSUER}/account/vault-token/${encodeURIComponent(vaultName)}`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers,
|
|
105
|
+
body: body.toString(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("handleAccountVaultTokenPost — happy path (assigned vault)", () => {
|
|
110
|
+
test("200 mints vault:<name>:read for an assigned vault, valid hub JWT", async () => {
|
|
111
|
+
const { friendId, cookie, csrfToken } = await seedFriend(["work"]);
|
|
112
|
+
const res = await handleAccountVaultTokenPost(
|
|
113
|
+
mintReq("work", { cookie, csrfToken, verb: "read" }),
|
|
114
|
+
"work",
|
|
115
|
+
deps(),
|
|
116
|
+
);
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
119
|
+
const html = await res.text();
|
|
120
|
+
expect(html).toContain('data-testid="minted-token-banner"');
|
|
121
|
+
|
|
122
|
+
// Pull the token out of the show-once banner and validate it as a hub JWT.
|
|
123
|
+
const m = html.match(/data-testid="minted-token-value">([^<]+)</);
|
|
124
|
+
expect(m).not.toBeNull();
|
|
125
|
+
const token = m![1] as string;
|
|
126
|
+
const validated = await validateAccessToken(harness.db, token, ISSUER);
|
|
127
|
+
expect(validated.payload.sub).toBe(friendId);
|
|
128
|
+
expect(validated.payload.iss).toBe(ISSUER);
|
|
129
|
+
expect(validated.payload.aud).toBe("vault.work");
|
|
130
|
+
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
131
|
+
expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:read"]);
|
|
132
|
+
// vault_scope pin — token can only ever be used against `work`.
|
|
133
|
+
expect((validated.payload as { vault_scope?: string[] }).vault_scope).toEqual(["work"]);
|
|
134
|
+
|
|
135
|
+
// TTL ≈ 90 days.
|
|
136
|
+
const expMs = new Date((validated.payload.exp ?? 0) * 1000).getTime();
|
|
137
|
+
const skew = expMs - Date.now();
|
|
138
|
+
expect(skew).toBeGreaterThan((ACCOUNT_VAULT_TOKEN_TTL_SECONDS - 60) * 1000);
|
|
139
|
+
expect(skew).toBeLessThan((ACCOUNT_VAULT_TOKEN_TTL_SECONDS + 60) * 1000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("200 mints vault:<name>:write when verb=write (default-role assignment)", async () => {
|
|
143
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
144
|
+
const res = await handleAccountVaultTokenPost(
|
|
145
|
+
mintReq("work", { cookie, csrfToken, verb: "write" }),
|
|
146
|
+
"work",
|
|
147
|
+
deps(),
|
|
148
|
+
);
|
|
149
|
+
expect(res.status).toBe(200);
|
|
150
|
+
const html = await res.text();
|
|
151
|
+
const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
|
|
152
|
+
const validated = await validateAccessToken(harness.db, token, ISSUER);
|
|
153
|
+
const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
|
|
154
|
+
expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:write"]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a friend assigned to multiple vaults can mint for each, never cross-vault", async () => {
|
|
158
|
+
const { cookie, csrfToken } = await seedFriend(["work", "home"]);
|
|
159
|
+
for (const v of ["work", "home"]) {
|
|
160
|
+
const res = await handleAccountVaultTokenPost(
|
|
161
|
+
mintReq(v, { cookie, csrfToken, verb: "read" }),
|
|
162
|
+
v,
|
|
163
|
+
deps(),
|
|
164
|
+
);
|
|
165
|
+
expect(res.status).toBe(200);
|
|
166
|
+
const html = await res.text();
|
|
167
|
+
const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
|
|
168
|
+
const validated = await validateAccessToken(harness.db, token, ISSUER);
|
|
169
|
+
expect(validated.payload.aud).toBe(`vault.${v}`);
|
|
170
|
+
}
|
|
171
|
+
// ...but a vault NOT in {work, home} is refused.
|
|
172
|
+
const res = await handleAccountVaultTokenPost(
|
|
173
|
+
mintReq("secret", { cookie, csrfToken, verb: "read" }),
|
|
174
|
+
"secret",
|
|
175
|
+
deps(),
|
|
176
|
+
);
|
|
177
|
+
expect(res.status).toBe(403);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("handleAccountVaultTokenPost — authorization gates (adversarial)", () => {
|
|
182
|
+
test("401 when no session cookie is present", async () => {
|
|
183
|
+
// Even with a CSRF token, no session = no identity = no mint.
|
|
184
|
+
const { token, cookieFragment } = csrfPair();
|
|
185
|
+
const res = await handleAccountVaultTokenPost(
|
|
186
|
+
mintReq("work", { cookie: cookieFragment, csrfToken: token, verb: "read" }),
|
|
187
|
+
"work",
|
|
188
|
+
deps(),
|
|
189
|
+
);
|
|
190
|
+
expect(res.status).toBe(401);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("403 when minting for a vault the friend is NOT assigned to (cross-vault)", async () => {
|
|
194
|
+
// Friend is assigned to `work` only; attempts `other`.
|
|
195
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
196
|
+
const res = await handleAccountVaultTokenPost(
|
|
197
|
+
mintReq("other", { cookie, csrfToken, verb: "read" }),
|
|
198
|
+
"other",
|
|
199
|
+
deps(),
|
|
200
|
+
);
|
|
201
|
+
expect(res.status).toBe(403);
|
|
202
|
+
const html = await res.text();
|
|
203
|
+
expect(html).toContain('data-testid="mint-error-banner"');
|
|
204
|
+
expect(html).toContain("not assigned");
|
|
205
|
+
// Critically: no token was minted.
|
|
206
|
+
expect(html).not.toContain('data-testid="minted-token-banner"');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a non-assigned friend cannot mint even for a vault that EXISTS for another user", async () => {
|
|
210
|
+
// Two friends; friend B is assigned to `shared`, friend A is not.
|
|
211
|
+
await createUser(harness.db, "operator", "operator-password-123");
|
|
212
|
+
const friendB = await createUser(harness.db, "bee", "bee-password-12345", {
|
|
213
|
+
assignedVaults: ["shared"],
|
|
214
|
+
allowMulti: true,
|
|
215
|
+
});
|
|
216
|
+
expect(friendB.id).toBeTruthy();
|
|
217
|
+
const friendA = await createUser(harness.db, "aay", "aay-password-12345", {
|
|
218
|
+
assignedVaults: ["mine"],
|
|
219
|
+
allowMulti: true,
|
|
220
|
+
});
|
|
221
|
+
const sessionA = createSession(harness.db, { userId: friendA.id });
|
|
222
|
+
const { token, cookieFragment } = csrfPair();
|
|
223
|
+
const cookie = `${buildSessionCookie(sessionA.id, Math.floor(SESSION_TTL_MS / 1000))}; ${cookieFragment}`;
|
|
224
|
+
const res = await handleAccountVaultTokenPost(
|
|
225
|
+
mintReq("shared", { cookie, csrfToken: token, verb: "read" }),
|
|
226
|
+
"shared",
|
|
227
|
+
deps(),
|
|
228
|
+
);
|
|
229
|
+
expect(res.status).toBe(403);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("403 — the first admin cannot mint here (no user_vaults rows; uses SPA path)", async () => {
|
|
233
|
+
// The first-created user is the unrestricted admin: empty assignedVaults,
|
|
234
|
+
// so vaultVerbsForUserVault returns null for every vault → 403. Admins
|
|
235
|
+
// mint via /admin/vault-admin-token, not this friend surface.
|
|
236
|
+
const admin = await createUser(harness.db, "operator", "operator-password-123");
|
|
237
|
+
const session = createSession(harness.db, { userId: admin.id });
|
|
238
|
+
const { token, cookieFragment } = csrfPair();
|
|
239
|
+
const cookie = `${buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000))}; ${cookieFragment}`;
|
|
240
|
+
const res = await handleAccountVaultTokenPost(
|
|
241
|
+
mintReq("work", { cookie, csrfToken: token, verb: "read" }),
|
|
242
|
+
"work",
|
|
243
|
+
deps(),
|
|
244
|
+
);
|
|
245
|
+
expect(res.status).toBe(403);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("admin verb is rejected — never mints vault:<name>:admin", async () => {
|
|
249
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
250
|
+
const res = await handleAccountVaultTokenPost(
|
|
251
|
+
mintReq("work", { cookie, csrfToken, verb: "admin" }),
|
|
252
|
+
"work",
|
|
253
|
+
deps(),
|
|
254
|
+
);
|
|
255
|
+
expect(res.status).toBe(400);
|
|
256
|
+
const html = await res.text();
|
|
257
|
+
expect(html).not.toContain('data-testid="minted-token-banner"');
|
|
258
|
+
expect(html).not.toContain("vault:work:admin");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("a garbage / broader verb is rejected", async () => {
|
|
262
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
263
|
+
for (const verb of ["host", "delete", "read write", "*", ""]) {
|
|
264
|
+
const res = await handleAccountVaultTokenPost(
|
|
265
|
+
mintReq("work", { cookie, csrfToken, verb }),
|
|
266
|
+
"work",
|
|
267
|
+
deps(),
|
|
268
|
+
);
|
|
269
|
+
expect(res.status).toBe(400);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a syntactically invalid vault name is rejected before any mint", async () => {
|
|
274
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
275
|
+
const res = await handleAccountVaultTokenPost(
|
|
276
|
+
mintReq("has..dots", { cookie, csrfToken, verb: "read" }),
|
|
277
|
+
"has..dots",
|
|
278
|
+
deps(),
|
|
279
|
+
);
|
|
280
|
+
expect(res.status).toBe(400);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("handleAccountVaultTokenPost — CSRF + method + rate limit", () => {
|
|
285
|
+
test("405 on non-POST", async () => {
|
|
286
|
+
const { cookie } = await seedFriend(["work"]);
|
|
287
|
+
const req = new Request(`${ISSUER}/account/vault-token/work`, {
|
|
288
|
+
method: "GET",
|
|
289
|
+
headers: { cookie },
|
|
290
|
+
});
|
|
291
|
+
const res = await handleAccountVaultTokenPost(req, "work", deps());
|
|
292
|
+
expect(res.status).toBe(405);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("400 when the CSRF token is missing", async () => {
|
|
296
|
+
const { cookie } = await seedFriend(["work"]);
|
|
297
|
+
const res = await handleAccountVaultTokenPost(
|
|
298
|
+
mintReq("work", { cookie, omitCsrf: true, verb: "read" }),
|
|
299
|
+
"work",
|
|
300
|
+
deps(),
|
|
301
|
+
);
|
|
302
|
+
expect(res.status).toBe(400);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("400 when the CSRF form token does not match the cookie", async () => {
|
|
306
|
+
const { cookie } = await seedFriend(["work"]);
|
|
307
|
+
// Send a different (non-matching) CSRF token in the form than the cookie.
|
|
308
|
+
const res = await handleAccountVaultTokenPost(
|
|
309
|
+
mintReq("work", { cookie, csrfToken: generateCsrfToken(), verb: "read" }),
|
|
310
|
+
"work",
|
|
311
|
+
deps(),
|
|
312
|
+
);
|
|
313
|
+
expect(res.status).toBe(400);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("429 once the per-user mint bucket fills (10 / 10 min)", async () => {
|
|
317
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
318
|
+
// 10 admitted, 11th denied.
|
|
319
|
+
for (let i = 0; i < 10; i++) {
|
|
320
|
+
const res = await handleAccountVaultTokenPost(
|
|
321
|
+
mintReq("work", { cookie, csrfToken, verb: "read" }),
|
|
322
|
+
"work",
|
|
323
|
+
deps(),
|
|
324
|
+
);
|
|
325
|
+
expect(res.status).toBe(200);
|
|
326
|
+
}
|
|
327
|
+
const denied = await handleAccountVaultTokenPost(
|
|
328
|
+
mintReq("work", { cookie, csrfToken, verb: "read" }),
|
|
329
|
+
"work",
|
|
330
|
+
deps(),
|
|
331
|
+
);
|
|
332
|
+
expect(denied.status).toBe(429);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("CSRF failure does NOT burn a rate-limit slot", async () => {
|
|
336
|
+
// A cross-site POST with a bad CSRF token should 400 before the bucket is
|
|
337
|
+
// touched — otherwise an attacker could exhaust the victim's mint bucket.
|
|
338
|
+
const { cookie, csrfToken } = await seedFriend(["work"]);
|
|
339
|
+
for (let i = 0; i < 15; i++) {
|
|
340
|
+
const res = await handleAccountVaultTokenPost(
|
|
341
|
+
mintReq("work", { cookie, csrfToken: generateCsrfToken(), verb: "read" }),
|
|
342
|
+
"work",
|
|
343
|
+
deps(),
|
|
344
|
+
);
|
|
345
|
+
expect(res.status).toBe(400);
|
|
346
|
+
}
|
|
347
|
+
// The legitimate mint still succeeds — the bucket was never touched.
|
|
348
|
+
const ok = await handleAccountVaultTokenPost(
|
|
349
|
+
mintReq("work", { cookie, csrfToken, verb: "read" }),
|
|
350
|
+
"work",
|
|
351
|
+
deps(),
|
|
352
|
+
);
|
|
353
|
+
expect(ok.status).toBe(200);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
exposeCloudflareOff,
|
|
15
15
|
exposeCloudflareUp,
|
|
16
16
|
} from "../commands/expose-cloudflare.ts";
|
|
17
|
+
import { readEnvFileValues } from "../env-file.ts";
|
|
17
18
|
import { readExposeState } from "../expose-state.ts";
|
|
18
19
|
import { writeHubPort } from "../hub-control.ts";
|
|
19
20
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
@@ -273,6 +274,121 @@ describe("exposeCloudflareUp", () => {
|
|
|
273
274
|
}
|
|
274
275
|
});
|
|
275
276
|
|
|
277
|
+
test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
|
|
278
|
+
// The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
|
|
279
|
+
// unlike the Tailscale path, which auto-restarts vault and so flows the
|
|
280
|
+
// public origin into vault/.env via lifecycle's persistVaultHubOrigin —
|
|
281
|
+
// never touched vault's .env or restarted it. The launchd/systemd daemon
|
|
282
|
+
// kept booting vault with NO PARACHUTE_HUB_ORIGIN → vault fell back to
|
|
283
|
+
// loopback as its expected issuer → every hub-minted token (iss=public)
|
|
284
|
+
// failed the iss check → 401. This asserts the durable .env write + the
|
|
285
|
+
// running-vault restart that mirrors the Tailscale path.
|
|
286
|
+
const env = makeEnv();
|
|
287
|
+
try {
|
|
288
|
+
// Seed vault as "running" so the restart branch fires. PID lives at
|
|
289
|
+
// <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
|
|
290
|
+
const vaultRun = join(env.configDir, "vault", "run");
|
|
291
|
+
require("node:fs").mkdirSync(vaultRun, { recursive: true });
|
|
292
|
+
writeFileSync(join(vaultRun, "vault.pid"), "99001");
|
|
293
|
+
|
|
294
|
+
const uuid = "ffffffff-0000-0000-0000-000000000006";
|
|
295
|
+
const { runner } = queueRunner([
|
|
296
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
297
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
298
|
+
{
|
|
299
|
+
code: 0,
|
|
300
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
301
|
+
stderr: "",
|
|
302
|
+
},
|
|
303
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
304
|
+
]);
|
|
305
|
+
const { spawner } = fakeSpawner(42300);
|
|
306
|
+
const restarted: string[] = [];
|
|
307
|
+
|
|
308
|
+
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
309
|
+
runner,
|
|
310
|
+
spawner,
|
|
311
|
+
// `alive` reports the seeded vault pid as running so processState() ===
|
|
312
|
+
// "running" and the restart branch executes.
|
|
313
|
+
alive: (pid) => pid === 99001,
|
|
314
|
+
kill: () => {},
|
|
315
|
+
log: () => {},
|
|
316
|
+
manifestPath: env.manifestPath,
|
|
317
|
+
statePath: env.statePath,
|
|
318
|
+
exposeStatePath: env.exposeStatePath,
|
|
319
|
+
configPath: env.configPath,
|
|
320
|
+
logPath: env.logPath,
|
|
321
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
322
|
+
configDir: env.configDir,
|
|
323
|
+
skipHub: true,
|
|
324
|
+
restartService: async (short) => {
|
|
325
|
+
restarted.push(short);
|
|
326
|
+
return 0;
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(code).toBe(0);
|
|
331
|
+
// Durable half: the public origin is written to vault/.env (NOT loopback,
|
|
332
|
+
// NOT unset) so the daemon boot path validates iss against it.
|
|
333
|
+
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
334
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
335
|
+
);
|
|
336
|
+
// Live half: the running vault is restarted to re-read the new origin.
|
|
337
|
+
expect(restarted).toEqual(["vault"]);
|
|
338
|
+
} finally {
|
|
339
|
+
env.cleanup();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("persists vault/.env but does NOT restart when vault isn't running", async () => {
|
|
344
|
+
// No vault pidfile → processState() !== "running" → no restart, but the
|
|
345
|
+
// durable .env write still happens so the next daemon boot is correct.
|
|
346
|
+
const env = makeEnv();
|
|
347
|
+
try {
|
|
348
|
+
const uuid = "ffffffff-0000-0000-0000-000000000007";
|
|
349
|
+
const { runner } = queueRunner([
|
|
350
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
351
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
352
|
+
{
|
|
353
|
+
code: 0,
|
|
354
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
355
|
+
stderr: "",
|
|
356
|
+
},
|
|
357
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
358
|
+
]);
|
|
359
|
+
const { spawner } = fakeSpawner(42301);
|
|
360
|
+
const restarted: string[] = [];
|
|
361
|
+
|
|
362
|
+
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
363
|
+
runner,
|
|
364
|
+
spawner,
|
|
365
|
+
alive: () => false,
|
|
366
|
+
kill: () => {},
|
|
367
|
+
log: () => {},
|
|
368
|
+
manifestPath: env.manifestPath,
|
|
369
|
+
statePath: env.statePath,
|
|
370
|
+
exposeStatePath: env.exposeStatePath,
|
|
371
|
+
configPath: env.configPath,
|
|
372
|
+
logPath: env.logPath,
|
|
373
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
374
|
+
configDir: env.configDir,
|
|
375
|
+
skipHub: true,
|
|
376
|
+
restartService: async (short) => {
|
|
377
|
+
restarted.push(short);
|
|
378
|
+
return 0;
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(code).toBe(0);
|
|
383
|
+
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
384
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
385
|
+
);
|
|
386
|
+
expect(restarted).toEqual([]);
|
|
387
|
+
} finally {
|
|
388
|
+
env.cleanup();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
276
392
|
test("reuses existing tunnel when name already present", async () => {
|
|
277
393
|
const env = makeEnv();
|
|
278
394
|
try {
|