@openparachute/hub 0.5.2 → 0.5.9-rc.6
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__/admin-clients.test.ts +275 -0
- package/src/__tests__/admin-handlers.test.ts +159 -320
- package/src/__tests__/admin-host-admin-token.test.ts +52 -4
- package/src/__tests__/api-me.test.ts +149 -0
- package/src/__tests__/api-mint-token.test.ts +381 -0
- package/src/__tests__/api-revocation-list.test.ts +198 -0
- package/src/__tests__/api-revoke-token.test.ts +320 -0
- package/src/__tests__/api-tokens.test.ts +629 -0
- package/src/__tests__/auth.test.ts +680 -16
- package/src/__tests__/expose-2fa-warning.test.ts +123 -0
- package/src/__tests__/expose-cloudflare.test.ts +101 -0
- package/src/__tests__/expose.test.ts +199 -340
- package/src/__tests__/hub-server.test.ts +986 -66
- package/src/__tests__/hub.test.ts +108 -55
- package/src/__tests__/install-source.test.ts +249 -0
- package/src/__tests__/install.test.ts +50 -31
- package/src/__tests__/jwt-sign.test.ts +205 -0
- package/src/__tests__/lifecycle.test.ts +97 -2
- package/src/__tests__/module-manifest.test.ts +48 -0
- package/src/__tests__/notes-serve.test.ts +154 -2
- package/src/__tests__/oauth-handlers.test.ts +1000 -3
- package/src/__tests__/operator-token.test.ts +379 -3
- package/src/__tests__/origin-check.test.ts +220 -0
- package/src/__tests__/port-assign.test.ts +41 -52
- package/src/__tests__/rate-limit.test.ts +190 -0
- package/src/__tests__/services-manifest.test.ts +341 -0
- package/src/__tests__/setup.test.ts +12 -9
- package/src/__tests__/status.test.ts +372 -0
- package/src/__tests__/well-known.test.ts +69 -0
- package/src/admin-clients.ts +139 -0
- package/src/admin-handlers.ts +63 -260
- package/src/admin-host-admin-token.ts +25 -10
- package/src/admin-login-ui.ts +256 -0
- package/src/admin-vault-admin-token.ts +1 -1
- package/src/api-me.ts +124 -0
- package/src/api-mint-token.ts +239 -0
- package/src/api-revocation-list.ts +59 -0
- package/src/api-revoke-token.ts +153 -0
- package/src/api-tokens.ts +224 -0
- package/src/commands/auth.ts +408 -51
- package/src/commands/expose-2fa-warning.ts +82 -0
- package/src/commands/expose-cloudflare.ts +27 -0
- package/src/commands/expose-public-auto.ts +3 -7
- package/src/commands/expose.ts +88 -173
- package/src/commands/install.ts +11 -13
- package/src/commands/lifecycle.ts +53 -4
- package/src/commands/status.ts +99 -8
- package/src/csrf.ts +6 -3
- package/src/help.ts +13 -7
- package/src/hub-db.ts +63 -0
- package/src/hub-server.ts +572 -106
- package/src/hub.ts +272 -149
- package/src/install-source.ts +291 -0
- package/src/jwt-sign.ts +265 -5
- package/src/module-manifest.ts +48 -10
- package/src/notes-serve.ts +70 -9
- package/src/oauth-handlers.ts +395 -29
- package/src/oauth-ui.ts +188 -0
- package/src/operator-token.ts +272 -18
- package/src/origin-check.ts +127 -0
- package/src/port-assign.ts +28 -35
- package/src/rate-limit.ts +166 -0
- package/src/scope-explanations.ts +33 -2
- package/src/service-spec.ts +58 -13
- package/src/services-manifest.ts +62 -3
- package/src/sessions.ts +19 -0
- package/src/well-known.ts +54 -1
- package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
- package/web/ui/dist/assets/index-D54otIhv.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/admin-config.test.ts +0 -281
- package/src/admin-config-ui.ts +0 -534
- package/src/admin-config.ts +0 -226
- package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
- package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
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
6
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
7
|
-
import { validateAccessToken } from "../jwt-sign.ts";
|
|
7
|
+
import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
8
8
|
import {
|
|
9
9
|
OPERATOR_TOKEN_AUDIENCE,
|
|
10
|
+
OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS,
|
|
11
|
+
OPERATOR_TOKEN_CLIENT_ID,
|
|
10
12
|
OPERATOR_TOKEN_FILENAME,
|
|
11
13
|
OPERATOR_TOKEN_SCOPES,
|
|
14
|
+
OPERATOR_TOKEN_SCOPE_SETS,
|
|
15
|
+
OPERATOR_TOKEN_SCOPE_SET_CLAIM,
|
|
12
16
|
OPERATOR_TOKEN_TTL_SECONDS,
|
|
13
17
|
issueOperatorToken,
|
|
14
18
|
mintOperatorToken,
|
|
15
19
|
operatorTokenPath,
|
|
16
20
|
readOperatorTokenFile,
|
|
21
|
+
useOperatorTokenWithAutoRotate,
|
|
17
22
|
writeOperatorTokenFile,
|
|
18
23
|
} from "../operator-token.ts";
|
|
19
24
|
import { rotateSigningKey } from "../signing-keys.ts";
|
|
@@ -58,10 +63,19 @@ describe("mintOperatorToken", () => {
|
|
|
58
63
|
}
|
|
59
64
|
});
|
|
60
65
|
|
|
61
|
-
test("
|
|
66
|
+
test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/channel admins (#213)", () => {
|
|
67
|
+
// OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
|
|
68
|
+
// alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
|
|
69
|
+
// parachute:host:install/start/expose/auth/vault scopes to the admin
|
|
70
|
+
// superset (admin is "everything", per the scope-set vocabulary).
|
|
62
71
|
expect(OPERATOR_TOKEN_SCOPES).toEqual([
|
|
63
72
|
"hub:admin",
|
|
64
73
|
"parachute:host:admin",
|
|
74
|
+
"parachute:host:install",
|
|
75
|
+
"parachute:host:start",
|
|
76
|
+
"parachute:host:expose",
|
|
77
|
+
"parachute:host:auth",
|
|
78
|
+
"parachute:host:vault",
|
|
65
79
|
"vault:admin",
|
|
66
80
|
"scribe:admin",
|
|
67
81
|
"channel:send",
|
|
@@ -138,3 +152,365 @@ describe("issueOperatorToken", () => {
|
|
|
138
152
|
}
|
|
139
153
|
});
|
|
140
154
|
});
|
|
155
|
+
|
|
156
|
+
describe("operator token defaults (#213)", () => {
|
|
157
|
+
test("default lifetime is 90d (was 365d through 0.5.7)", () => {
|
|
158
|
+
expect(OPERATOR_TOKEN_TTL_SECONDS).toBe(90 * 24 * 60 * 60);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("auto-rotate threshold is 7d", () => {
|
|
162
|
+
expect(OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS).toBe(7 * 24 * 60 * 60);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("mintOperatorToken scope-sets (#213)", () => {
|
|
167
|
+
test("default scope-set is admin and embeds the pa_scope_set claim", async () => {
|
|
168
|
+
const h = makeHarness();
|
|
169
|
+
try {
|
|
170
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
171
|
+
try {
|
|
172
|
+
rotateSigningKey(db);
|
|
173
|
+
const minted = await mintOperatorToken(db, "user-abc", { issuer: TEST_ISSUER });
|
|
174
|
+
expect(minted.scopeSet).toBe("admin");
|
|
175
|
+
const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
|
|
176
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("admin");
|
|
177
|
+
expect(validated.payload.scope).toBe(OPERATOR_TOKEN_SCOPE_SETS.admin.join(" "));
|
|
178
|
+
} finally {
|
|
179
|
+
db.close();
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
h.cleanup();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("--scope-set=start mints with parachute:host:start only", async () => {
|
|
187
|
+
const h = makeHarness();
|
|
188
|
+
try {
|
|
189
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
190
|
+
try {
|
|
191
|
+
rotateSigningKey(db);
|
|
192
|
+
const minted = await mintOperatorToken(db, "user-abc", {
|
|
193
|
+
issuer: TEST_ISSUER,
|
|
194
|
+
scopeSet: "start",
|
|
195
|
+
});
|
|
196
|
+
expect(minted.scopeSet).toBe("start");
|
|
197
|
+
const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
|
|
198
|
+
expect(validated.payload.scope).toBe("parachute:host:start");
|
|
199
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
200
|
+
} finally {
|
|
201
|
+
db.close();
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
h.cleanup();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("install scope-set carries vault:read for new-vault discovery", async () => {
|
|
209
|
+
const h = makeHarness();
|
|
210
|
+
try {
|
|
211
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
212
|
+
try {
|
|
213
|
+
rotateSigningKey(db);
|
|
214
|
+
const minted = await mintOperatorToken(db, "u", {
|
|
215
|
+
issuer: TEST_ISSUER,
|
|
216
|
+
scopeSet: "install",
|
|
217
|
+
});
|
|
218
|
+
const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
|
|
219
|
+
const scopes = String(validated.payload.scope ?? "").split(" ");
|
|
220
|
+
expect(scopes).toContain("parachute:host:install");
|
|
221
|
+
expect(scopes).toContain("vault:read");
|
|
222
|
+
} finally {
|
|
223
|
+
db.close();
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
h.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("admin set is the superset of all narrow sets", () => {
|
|
231
|
+
const admin = new Set(OPERATOR_TOKEN_SCOPE_SETS.admin);
|
|
232
|
+
for (const setName of ["install", "start", "expose", "auth", "vault"] as const) {
|
|
233
|
+
for (const scope of OPERATOR_TOKEN_SCOPE_SETS[setName]) {
|
|
234
|
+
// vault:read is in `install` but not (directly) in admin — admin
|
|
235
|
+
// carries vault:admin which subsumes :read at the resource server.
|
|
236
|
+
if (scope === "vault:read") continue;
|
|
237
|
+
expect(admin.has(scope)).toBe(true);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("readOperatorTokenFile permission warning (#213)", () => {
|
|
244
|
+
test("does not warn when file is mode 0600", async () => {
|
|
245
|
+
const h = makeHarness();
|
|
246
|
+
const origErr = console.error;
|
|
247
|
+
let stderr = "";
|
|
248
|
+
console.error = (...a: unknown[]) => {
|
|
249
|
+
stderr += `${a.map(String).join(" ")}\n`;
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
await writeOperatorTokenFile("token-abc", h.dir);
|
|
253
|
+
await readOperatorTokenFile(h.dir);
|
|
254
|
+
expect(stderr).toBe("");
|
|
255
|
+
} finally {
|
|
256
|
+
console.error = origErr;
|
|
257
|
+
h.cleanup();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("warns (without failing) when file is world-readable", async () => {
|
|
262
|
+
const h = makeHarness();
|
|
263
|
+
const origErr = console.error;
|
|
264
|
+
let stderr = "";
|
|
265
|
+
console.error = (...a: unknown[]) => {
|
|
266
|
+
stderr += `${a.map(String).join(" ")}\n`;
|
|
267
|
+
};
|
|
268
|
+
try {
|
|
269
|
+
const path = await writeOperatorTokenFile("token-abc", h.dir);
|
|
270
|
+
chmodSync(path, 0o644);
|
|
271
|
+
const round = await readOperatorTokenFile(h.dir);
|
|
272
|
+
expect(round).toBe("token-abc");
|
|
273
|
+
expect(stderr).toContain("operator token file");
|
|
274
|
+
expect(stderr).toContain("0644");
|
|
275
|
+
expect(stderr).toContain("chmod 0600");
|
|
276
|
+
} finally {
|
|
277
|
+
console.error = origErr;
|
|
278
|
+
h.cleanup();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("useOperatorTokenWithAutoRotate (#213)", () => {
|
|
284
|
+
test("returns the token unchanged when remaining lifetime > threshold", async () => {
|
|
285
|
+
const h = makeHarness();
|
|
286
|
+
try {
|
|
287
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
288
|
+
try {
|
|
289
|
+
rotateSigningKey(db);
|
|
290
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
291
|
+
dir: h.dir,
|
|
292
|
+
issuer: TEST_ISSUER,
|
|
293
|
+
// Default 90d, fresh — well above threshold.
|
|
294
|
+
});
|
|
295
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
296
|
+
configDir: h.dir,
|
|
297
|
+
issuer: TEST_ISSUER,
|
|
298
|
+
});
|
|
299
|
+
expect(used).not.toBeNull();
|
|
300
|
+
expect(used?.refreshed).toBe(false);
|
|
301
|
+
expect(used?.rotated).toBeUndefined();
|
|
302
|
+
expect(used?.token).toBe(issued.token);
|
|
303
|
+
} finally {
|
|
304
|
+
db.close();
|
|
305
|
+
}
|
|
306
|
+
} finally {
|
|
307
|
+
h.cleanup();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("auto-rotates when within 7d of expiry, preserving scope-set", async () => {
|
|
312
|
+
const h = makeHarness();
|
|
313
|
+
try {
|
|
314
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
315
|
+
try {
|
|
316
|
+
rotateSigningKey(db);
|
|
317
|
+
// Mint with a 1-day TTL — well below the 7d threshold.
|
|
318
|
+
const original = await issueOperatorToken(db, "user-abc", {
|
|
319
|
+
dir: h.dir,
|
|
320
|
+
issuer: TEST_ISSUER,
|
|
321
|
+
scopeSet: "start",
|
|
322
|
+
ttlSeconds: 24 * 60 * 60,
|
|
323
|
+
});
|
|
324
|
+
expect(original.scopeSet).toBe("start");
|
|
325
|
+
|
|
326
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
327
|
+
configDir: h.dir,
|
|
328
|
+
issuer: TEST_ISSUER,
|
|
329
|
+
});
|
|
330
|
+
expect(used).not.toBeNull();
|
|
331
|
+
expect(used?.refreshed).toBe(true);
|
|
332
|
+
expect(used?.rotated?.scopeSet).toBe("start");
|
|
333
|
+
// The on-disk token is now the rotated one.
|
|
334
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
335
|
+
expect(onDisk).toBe(used!.token);
|
|
336
|
+
expect(onDisk).not.toBe(original.token);
|
|
337
|
+
// The rotated token is still scope-set "start".
|
|
338
|
+
const validated = await validateAccessToken(db, used!.token, TEST_ISSUER);
|
|
339
|
+
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
340
|
+
expect(validated.payload.scope).toBe("parachute:host:start");
|
|
341
|
+
} finally {
|
|
342
|
+
db.close();
|
|
343
|
+
}
|
|
344
|
+
} finally {
|
|
345
|
+
h.cleanup();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("does NOT auto-rotate a non-operator-audience JWT stashed at the path (privilege guard)", async () => {
|
|
350
|
+
const h = makeHarness();
|
|
351
|
+
try {
|
|
352
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
353
|
+
try {
|
|
354
|
+
rotateSigningKey(db);
|
|
355
|
+
// Hand-sign a narrow JWT with aud=scribe (not "operator") and a
|
|
356
|
+
// 1-hour TTL. Even though it's within the rotation window, the
|
|
357
|
+
// helper must not silently upgrade it to a full operator token.
|
|
358
|
+
const signed = await signAccessToken(db, {
|
|
359
|
+
sub: "user-abc",
|
|
360
|
+
scopes: ["scribe:transcribe"],
|
|
361
|
+
audience: "scribe",
|
|
362
|
+
clientId: OPERATOR_TOKEN_CLIENT_ID,
|
|
363
|
+
issuer: TEST_ISSUER,
|
|
364
|
+
ttlSeconds: 3600,
|
|
365
|
+
});
|
|
366
|
+
await writeOperatorTokenFile(signed.token, h.dir);
|
|
367
|
+
|
|
368
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
369
|
+
configDir: h.dir,
|
|
370
|
+
issuer: TEST_ISSUER,
|
|
371
|
+
});
|
|
372
|
+
expect(used).not.toBeNull();
|
|
373
|
+
expect(used?.refreshed).toBe(false);
|
|
374
|
+
expect(used?.rotated).toBeUndefined();
|
|
375
|
+
expect(used?.token).toBe(signed.token);
|
|
376
|
+
// On-disk file unchanged.
|
|
377
|
+
const onDisk = await readOperatorTokenFile(h.dir);
|
|
378
|
+
expect(onDisk).toBe(signed.token);
|
|
379
|
+
} finally {
|
|
380
|
+
db.close();
|
|
381
|
+
}
|
|
382
|
+
} finally {
|
|
383
|
+
h.cleanup();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("returns null when no operator token file exists", async () => {
|
|
388
|
+
const h = makeHarness();
|
|
389
|
+
try {
|
|
390
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
391
|
+
try {
|
|
392
|
+
rotateSigningKey(db);
|
|
393
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
394
|
+
configDir: h.dir,
|
|
395
|
+
issuer: TEST_ISSUER,
|
|
396
|
+
});
|
|
397
|
+
expect(used).toBeNull();
|
|
398
|
+
} finally {
|
|
399
|
+
db.close();
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
h.cleanup();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("validateAccessToken rejects a fully-expired token (jose enforces exp)", async () => {
|
|
407
|
+
const h = makeHarness();
|
|
408
|
+
try {
|
|
409
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
410
|
+
try {
|
|
411
|
+
rotateSigningKey(db);
|
|
412
|
+
// Mint a token that's already expired.
|
|
413
|
+
const expiredAt = new Date("2026-01-01T00:00:00Z");
|
|
414
|
+
const issued = await issueOperatorToken(db, "user-abc", {
|
|
415
|
+
dir: h.dir,
|
|
416
|
+
issuer: TEST_ISSUER,
|
|
417
|
+
ttlSeconds: 60,
|
|
418
|
+
now: () => expiredAt,
|
|
419
|
+
});
|
|
420
|
+
expect(issued.token.length).toBeGreaterThan(0);
|
|
421
|
+
await expect(
|
|
422
|
+
useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
|
|
423
|
+
).rejects.toThrow();
|
|
424
|
+
} finally {
|
|
425
|
+
db.close();
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
h.cleanup();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// closes #212 Phase 1 — operator-mint paths write to the unified token
|
|
434
|
+
// registry so they show up in the revocation list and admin UI alongside
|
|
435
|
+
// OAuth refresh tokens and CLI mints.
|
|
436
|
+
describe("mintOperatorToken registry write (#212)", () => {
|
|
437
|
+
test("writes a tokens row with created_via='operator_mint', subject='operator', user_id NULL", async () => {
|
|
438
|
+
const h = makeHarness();
|
|
439
|
+
try {
|
|
440
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
441
|
+
try {
|
|
442
|
+
rotateSigningKey(db);
|
|
443
|
+
const minted = await mintOperatorToken(db, "user-abc", {
|
|
444
|
+
issuer: TEST_ISSUER,
|
|
445
|
+
scopeSet: "start",
|
|
446
|
+
});
|
|
447
|
+
const row = db
|
|
448
|
+
.query<
|
|
449
|
+
{
|
|
450
|
+
jti: string;
|
|
451
|
+
user_id: string | null;
|
|
452
|
+
subject: string | null;
|
|
453
|
+
created_via: string;
|
|
454
|
+
scopes: string;
|
|
455
|
+
expires_at: string;
|
|
456
|
+
},
|
|
457
|
+
[string]
|
|
458
|
+
>(
|
|
459
|
+
"SELECT jti, user_id, subject, created_via, scopes, expires_at FROM tokens WHERE jti = ?",
|
|
460
|
+
)
|
|
461
|
+
.get(minted.jti);
|
|
462
|
+
expect(row).not.toBeNull();
|
|
463
|
+
expect(row?.user_id).toBeNull();
|
|
464
|
+
expect(row?.subject).toBe("operator");
|
|
465
|
+
expect(row?.created_via).toBe("operator_mint");
|
|
466
|
+
expect(row?.scopes).toBe("parachute:host:start");
|
|
467
|
+
expect(row?.expires_at).toBe(minted.expiresAt);
|
|
468
|
+
} finally {
|
|
469
|
+
db.close();
|
|
470
|
+
}
|
|
471
|
+
} finally {
|
|
472
|
+
h.cleanup();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("auto-rotation writes a fresh registry row for the rotated token", async () => {
|
|
477
|
+
const h = makeHarness();
|
|
478
|
+
try {
|
|
479
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
480
|
+
try {
|
|
481
|
+
rotateSigningKey(db);
|
|
482
|
+
const original = await issueOperatorToken(db, "user-abc", {
|
|
483
|
+
dir: h.dir,
|
|
484
|
+
issuer: TEST_ISSUER,
|
|
485
|
+
ttlSeconds: 24 * 60 * 60, // within rotation window
|
|
486
|
+
});
|
|
487
|
+
const used = await useOperatorTokenWithAutoRotate(db, {
|
|
488
|
+
configDir: h.dir,
|
|
489
|
+
issuer: TEST_ISSUER,
|
|
490
|
+
});
|
|
491
|
+
expect(used?.refreshed).toBe(true);
|
|
492
|
+
// The rotated token has a new jti.
|
|
493
|
+
const newJti = used!.payload.jti as string;
|
|
494
|
+
expect(newJti).not.toBe(original.jti);
|
|
495
|
+
const row = db
|
|
496
|
+
.query<{ jti: string; created_via: string }, [string]>(
|
|
497
|
+
"SELECT jti, created_via FROM tokens WHERE jti = ?",
|
|
498
|
+
)
|
|
499
|
+
.get(newJti);
|
|
500
|
+
expect(row).not.toBeNull();
|
|
501
|
+
expect(row?.created_via).toBe("operator_mint");
|
|
502
|
+
// Both the original and the rotated row exist (the original isn't
|
|
503
|
+
// auto-revoked — it stays valid until its own exp). Phase 2 may add
|
|
504
|
+
// a "revoke prior on rotation" toggle; for now we keep both.
|
|
505
|
+
const origRow = db
|
|
506
|
+
.query<{ jti: string }, [string]>("SELECT jti FROM tokens WHERE jti = ?")
|
|
507
|
+
.get(original.jti);
|
|
508
|
+
expect(origRow).not.toBeNull();
|
|
509
|
+
} finally {
|
|
510
|
+
db.close();
|
|
511
|
+
}
|
|
512
|
+
} finally {
|
|
513
|
+
h.cleanup();
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildHubBoundOrigins, isSameOriginRequest } from "../origin-check.ts";
|
|
3
|
+
|
|
4
|
+
const ISSUER = "https://parachute.taildf9ce2.ts.net";
|
|
5
|
+
const PORT = 1939;
|
|
6
|
+
|
|
7
|
+
function reqWithHeaders(headers: Record<string, string>): Request {
|
|
8
|
+
// Bun's Request constructor lower-cases header keys; tests pass whatever
|
|
9
|
+
// case is conventional in the wild. The URL is irrelevant — only the
|
|
10
|
+
// headers are inspected by isSameOriginRequest.
|
|
11
|
+
return new Request("http://placeholder/", { method: "POST", headers });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("buildHubBoundOrigins", () => {
|
|
15
|
+
test("issuer only — single-origin hub", () => {
|
|
16
|
+
expect(buildHubBoundOrigins({ issuer: ISSUER })).toEqual([ISSUER]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("issuer + loopback port adds localhost + 127.0.0.1 aliases", () => {
|
|
20
|
+
const origins = buildHubBoundOrigins({ issuer: ISSUER, loopbackPort: PORT });
|
|
21
|
+
expect(origins).toContain(ISSUER);
|
|
22
|
+
expect(origins).toContain(`http://localhost:${PORT}`);
|
|
23
|
+
expect(origins).toContain(`http://127.0.0.1:${PORT}`);
|
|
24
|
+
expect(origins.length).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("exposeHubOrigin adds a tailnet/funnel origin when distinct from issuer", () => {
|
|
28
|
+
// Scenario: hub was started with --issuer http://localhost:1939 (dev),
|
|
29
|
+
// then `parachute expose tailnet` brought up the tailnet hostname.
|
|
30
|
+
// exposeHubOrigin captures the post-expose hostname.
|
|
31
|
+
const origins = buildHubBoundOrigins({
|
|
32
|
+
issuer: "http://localhost:1939",
|
|
33
|
+
loopbackPort: PORT,
|
|
34
|
+
exposeHubOrigin: ISSUER,
|
|
35
|
+
});
|
|
36
|
+
expect(origins).toContain("http://localhost:1939");
|
|
37
|
+
expect(origins).toContain(ISSUER);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("dedups when exposeHubOrigin matches issuer", () => {
|
|
41
|
+
// Normal case: `parachute expose` set the issuer AND wrote the same
|
|
42
|
+
// hubOrigin to expose-state.json. The set should still be one entry
|
|
43
|
+
// for that origin, not two.
|
|
44
|
+
const origins = buildHubBoundOrigins({
|
|
45
|
+
issuer: ISSUER,
|
|
46
|
+
exposeHubOrigin: ISSUER,
|
|
47
|
+
});
|
|
48
|
+
expect(origins.filter((o) => o === ISSUER).length).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("malformed inputs are silently dropped", () => {
|
|
52
|
+
// No URL parser crash — return whatever could be parsed. The caller
|
|
53
|
+
// (resolveBoundOrigins) keeps the issuer as a baseline anyway.
|
|
54
|
+
const origins = buildHubBoundOrigins({
|
|
55
|
+
issuer: ISSUER,
|
|
56
|
+
exposeHubOrigin: "not a url",
|
|
57
|
+
});
|
|
58
|
+
expect(origins).toContain(ISSUER);
|
|
59
|
+
expect(origins.length).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("normalizes via URL.origin — trailing slash on issuer is stripped", () => {
|
|
63
|
+
const origins = buildHubBoundOrigins({ issuer: `${ISSUER}/` });
|
|
64
|
+
expect(origins).toEqual([ISSUER]); // URL.origin drops trailing slash
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("non-integer loopbackPort is ignored", () => {
|
|
68
|
+
// Belt for callers passing through a stringly-typed env var. We don't
|
|
69
|
+
// want to emit `http://localhost:NaN`.
|
|
70
|
+
const origins = buildHubBoundOrigins({
|
|
71
|
+
issuer: ISSUER,
|
|
72
|
+
loopbackPort: Number.NaN,
|
|
73
|
+
});
|
|
74
|
+
expect(origins.every((o) => !o.includes("NaN"))).toBe(true);
|
|
75
|
+
expect(origins).toContain(ISSUER);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("isSameOriginRequest", () => {
|
|
80
|
+
const BOUND = buildHubBoundOrigins({ issuer: ISSUER, loopbackPort: PORT });
|
|
81
|
+
|
|
82
|
+
describe("Origin header (primary)", () => {
|
|
83
|
+
test("accepts a request whose Origin matches the issuer", () => {
|
|
84
|
+
const req = reqWithHeaders({ origin: ISSUER });
|
|
85
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("accepts a request whose Origin matches loopback (localhost)", () => {
|
|
89
|
+
// Closes #245 Case A: operator on http://localhost:1939/login
|
|
90
|
+
// submitting the approve form — previously rejected because Origin
|
|
91
|
+
// (localhost) didn't match the configured issuer (tailnet).
|
|
92
|
+
const req = reqWithHeaders({ origin: `http://localhost:${PORT}` });
|
|
93
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("accepts a request whose Origin matches loopback (127.0.0.1)", () => {
|
|
97
|
+
const req = reqWithHeaders({ origin: `http://127.0.0.1:${PORT}` });
|
|
98
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rejects a real third-party origin", () => {
|
|
102
|
+
const req = reqWithHeaders({ origin: "https://attacker.example" });
|
|
103
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rejects a port-only mismatch", () => {
|
|
107
|
+
// A request from `http://localhost:1940` (different port) is NOT
|
|
108
|
+
// the hub — could be a different service on the same box. The
|
|
109
|
+
// bound set only includes the hub's own port.
|
|
110
|
+
const req = reqWithHeaders({ origin: "http://localhost:1940" });
|
|
111
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects scheme mismatch (https://localhost vs http://localhost)", () => {
|
|
115
|
+
// The bound set has http://localhost:<port>; an https://localhost
|
|
116
|
+
// request shouldn't match. Less likely in practice (loopback is
|
|
117
|
+
// typically http) but the URL.origin comparison catches it.
|
|
118
|
+
const req = reqWithHeaders({ origin: `https://localhost:${PORT}` });
|
|
119
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("malformed Origin string returns false (does not throw)", () => {
|
|
123
|
+
const req = reqWithHeaders({ origin: "not a valid url" });
|
|
124
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("Referer header (fallback when Origin is absent)", () => {
|
|
129
|
+
test("accepts when Referer matches a bound origin", () => {
|
|
130
|
+
const req = reqWithHeaders({ referer: `${ISSUER}/login` });
|
|
131
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("rejects when Referer is third-party", () => {
|
|
135
|
+
const req = reqWithHeaders({ referer: "https://attacker.example/page" });
|
|
136
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("Origin takes priority over Referer when both present", () => {
|
|
140
|
+
// If Origin says cross-origin, even a same-origin Referer doesn't
|
|
141
|
+
// rescue. Important: an attacker can sometimes spoof Referer (via
|
|
142
|
+
// a redirect chain) but cannot spoof Origin from a browser.
|
|
143
|
+
const req = reqWithHeaders({
|
|
144
|
+
origin: "https://attacker.example",
|
|
145
|
+
referer: ISSUER,
|
|
146
|
+
});
|
|
147
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("Host header (last-resort fallback when Origin + Referer both stripped)", () => {
|
|
152
|
+
// Closes #245 Case B: Tailscale Serve stripped Origin/Referer from a
|
|
153
|
+
// legitimate same-origin POST, so neither primary nor secondary
|
|
154
|
+
// signal was available. Host header reflected the tailnet hostname
|
|
155
|
+
// the browser thought it was talking to.
|
|
156
|
+
|
|
157
|
+
test("accepts when Host matches a bound origin's host:port", () => {
|
|
158
|
+
const req = reqWithHeaders({
|
|
159
|
+
host: new URL(ISSUER).host,
|
|
160
|
+
});
|
|
161
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("accepts loopback Host match", () => {
|
|
165
|
+
const req = reqWithHeaders({ host: `localhost:${PORT}` });
|
|
166
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("rejects a third-party Host", () => {
|
|
170
|
+
const req = reqWithHeaders({ host: "attacker.example" });
|
|
171
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("Origin takes priority over Host — Host-only check fires only when Origin+Referer absent", () => {
|
|
175
|
+
// Same belt-and-suspenders order: Origin says no → reject, even if
|
|
176
|
+
// Host happens to match. Otherwise an attacker who could induce a
|
|
177
|
+
// cross-origin POST without browser Origin (rare but theoretical)
|
|
178
|
+
// could pass with a manipulated Host. Origin remains the primary.
|
|
179
|
+
const req = reqWithHeaders({
|
|
180
|
+
origin: "https://attacker.example",
|
|
181
|
+
host: new URL(ISSUER).host,
|
|
182
|
+
});
|
|
183
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("Referer takes priority over Host", () => {
|
|
187
|
+
const req = reqWithHeaders({
|
|
188
|
+
referer: "https://attacker.example/page",
|
|
189
|
+
host: new URL(ISSUER).host,
|
|
190
|
+
});
|
|
191
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("no headers at all", () => {
|
|
196
|
+
test("rejects when Origin, Referer, AND Host are all absent", () => {
|
|
197
|
+
// Bun synthesizes a Host header from the URL, so we use new Headers()
|
|
198
|
+
// directly to clear it. The function's contract: with no signal, reject.
|
|
199
|
+
const req = new Request("http://placeholder/", {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: new Headers(),
|
|
202
|
+
});
|
|
203
|
+
// Bun will still inject Host from the URL, so simulate the stripped
|
|
204
|
+
// case by passing an explicit empty Host. If Bun adds the URL host,
|
|
205
|
+
// the check returns true for matching placeholder — but our bound
|
|
206
|
+
// origins don't include placeholder, so we still return false.
|
|
207
|
+
expect(isSameOriginRequest(req, BOUND)).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("empty bound-origin set (defense fails closed)", () => {
|
|
212
|
+
test("returns false regardless of headers when no origins are bound", () => {
|
|
213
|
+
// Mis-wired hub (no issuer, no exposeState, no port) — the function
|
|
214
|
+
// should reject everything rather than accept everything. Fail-closed
|
|
215
|
+
// is the right default for a CSRF defense.
|
|
216
|
+
const req = reqWithHeaders({ origin: ISSUER });
|
|
217
|
+
expect(isSameOriginRequest(req, [])).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|