@openparachute/hub 0.3.0-rc.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -17
- package/package.json +15 -4
- package/src/__tests__/admin-auth.test.ts +197 -0
- package/src/__tests__/admin-config.test.ts +281 -0
- package/src/__tests__/admin-grants.test.ts +271 -0
- package/src/__tests__/admin-handlers.test.ts +530 -0
- package/src/__tests__/admin-host-admin-token.test.ts +115 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
- package/src/__tests__/admin-vaults.test.ts +615 -0
- package/src/__tests__/auth-codes.test.ts +253 -0
- package/src/__tests__/auth.test.ts +1063 -17
- package/src/__tests__/cli.test.ts +50 -0
- package/src/__tests__/clients.test.ts +264 -0
- package/src/__tests__/cloudflare-state.test.ts +167 -7
- package/src/__tests__/csrf.test.ts +117 -0
- package/src/__tests__/expose-cloudflare.test.ts +232 -37
- package/src/__tests__/expose-off-auto.test.ts +15 -9
- package/src/__tests__/expose-public-auto.test.ts +153 -0
- package/src/__tests__/expose.test.ts +216 -24
- package/src/__tests__/grants.test.ts +164 -0
- package/src/__tests__/hub-db.test.ts +153 -0
- package/src/__tests__/hub-server.test.ts +984 -26
- package/src/__tests__/hub.test.ts +56 -49
- package/src/__tests__/install.test.ts +327 -3
- package/src/__tests__/jwks.test.ts +37 -0
- package/src/__tests__/jwt-sign.test.ts +361 -0
- package/src/__tests__/lifecycle.test.ts +616 -5
- package/src/__tests__/module-manifest.test.ts +183 -0
- package/src/__tests__/oauth-handlers.test.ts +3112 -0
- package/src/__tests__/oauth-ui.test.ts +253 -0
- package/src/__tests__/operator-token.test.ts +140 -0
- package/src/__tests__/providers-detect.test.ts +158 -0
- package/src/__tests__/scope-explanations.test.ts +108 -0
- package/src/__tests__/scope-registry.test.ts +220 -0
- package/src/__tests__/services-manifest.test.ts +137 -1
- package/src/__tests__/sessions.test.ts +116 -0
- package/src/__tests__/setup.test.ts +361 -0
- package/src/__tests__/signing-keys.test.ts +153 -0
- package/src/__tests__/upgrade.test.ts +541 -0
- package/src/__tests__/users.test.ts +154 -0
- package/src/__tests__/well-known.test.ts +127 -10
- package/src/admin-auth.ts +126 -0
- package/src/admin-config-ui.ts +534 -0
- package/src/admin-config.ts +226 -0
- package/src/admin-grants.ts +160 -0
- package/src/admin-handlers.ts +365 -0
- package/src/admin-host-admin-token.ts +83 -0
- package/src/admin-vault-admin-token.ts +98 -0
- package/src/admin-vaults.ts +359 -0
- package/src/auth-codes.ts +189 -0
- package/src/cli.ts +202 -25
- package/src/clients.ts +210 -0
- package/src/cloudflare/config.ts +25 -6
- package/src/cloudflare/state.ts +108 -28
- package/src/commands/auth.ts +851 -19
- package/src/commands/expose-cloudflare.ts +85 -45
- package/src/commands/expose-interactive.ts +20 -44
- package/src/commands/expose-off-auto.ts +27 -11
- package/src/commands/expose-public-auto.ts +179 -0
- package/src/commands/expose.ts +63 -32
- package/src/commands/install.ts +337 -48
- package/src/commands/lifecycle.ts +269 -38
- package/src/commands/setup.ts +366 -0
- package/src/commands/status.ts +4 -1
- package/src/commands/upgrade.ts +429 -0
- package/src/csrf.ts +101 -0
- package/src/grants.ts +142 -0
- package/src/help.ts +133 -19
- package/src/hub-control.ts +12 -0
- package/src/hub-db.ts +164 -0
- package/src/hub-server.ts +643 -22
- package/src/hub.ts +97 -390
- package/src/jwks.ts +41 -0
- package/src/jwt-audience.ts +40 -0
- package/src/jwt-sign.ts +275 -0
- package/src/module-manifest.ts +435 -0
- package/src/oauth-handlers.ts +1175 -0
- package/src/oauth-ui.ts +582 -0
- package/src/operator-token.ts +129 -0
- package/src/providers/detect.ts +97 -0
- package/src/scope-explanations.ts +137 -0
- package/src/scope-registry.ts +158 -0
- package/src/service-spec.ts +270 -97
- package/src/services-manifest.ts +57 -1
- package/src/sessions.ts +115 -0
- package/src/signing-keys.ts +120 -0
- package/src/users.ts +144 -0
- package/src/well-known.ts +62 -26
- package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
- package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
- package/web/ui/dist/index.html +14 -0
|
@@ -122,6 +122,56 @@ describe("cli per-subcommand help", () => {
|
|
|
122
122
|
expect(stderr).toMatch(/--cloudflare only applies to `public`/);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test("expose public --tailnet --cloudflare rejected as mutually exclusive", async () => {
|
|
126
|
+
const { code, stderr } = await runCli([
|
|
127
|
+
"expose",
|
|
128
|
+
"public",
|
|
129
|
+
"--tailnet",
|
|
130
|
+
"--cloudflare",
|
|
131
|
+
"--domain",
|
|
132
|
+
"vault.example.com",
|
|
133
|
+
]);
|
|
134
|
+
expect(code).toBe(1);
|
|
135
|
+
expect(stderr).toMatch(/mutually exclusive/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("expose tailnet --tailnet rejected (tailnet flag scoped to public layer)", async () => {
|
|
139
|
+
const { code, stderr } = await runCli(["expose", "tailnet", "--tailnet"]);
|
|
140
|
+
expect(code).toBe(1);
|
|
141
|
+
expect(stderr).toMatch(/--tailnet pins the public layer/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("expose --help mentions --tailnet, --skip-provider-check, --tunnel-name", async () => {
|
|
145
|
+
const { code, stdout } = await runCli(["expose", "--help"]);
|
|
146
|
+
expect(code).toBe(0);
|
|
147
|
+
expect(stdout).toMatch(/--tailnet\b/);
|
|
148
|
+
expect(stdout).toMatch(/--skip-provider-check\b/);
|
|
149
|
+
expect(stdout).toMatch(/--tunnel-name\b/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("expose public --skip-provider-check pins to Tailscale-Funnel default (skips auto-pick)", async () => {
|
|
153
|
+
// With PATH="" tailscale isn't on PATH, so exposePublic prints its own
|
|
154
|
+
// install hint. That's distinct from the auto-pick "no exposure provider
|
|
155
|
+
// is set up" output — proving the skip flag bypassed auto-pick and went
|
|
156
|
+
// straight to the Funnel path. If we regressed and skip-flag tumbled
|
|
157
|
+
// into auto-pick, we'd see the auto-pick neither-ready report instead.
|
|
158
|
+
const proc = Bun.spawn([process.execPath, CLI, "expose", "public", "--skip-provider-check"], {
|
|
159
|
+
stdout: "pipe",
|
|
160
|
+
stderr: "pipe",
|
|
161
|
+
env: {
|
|
162
|
+
...process.env,
|
|
163
|
+
PATH: "",
|
|
164
|
+
HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
165
|
+
PARACHUTE_HOME: "/tmp/parachute-hub-nonexistent-home",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
169
|
+
expect(code).toBe(1);
|
|
170
|
+
expect(stdout).toMatch(/tailscale is not installed or not on PATH/);
|
|
171
|
+
expect(stdout).not.toMatch(/no exposure provider is set up/);
|
|
172
|
+
expect(stdout).not.toMatch(/Auto-detected/);
|
|
173
|
+
});
|
|
174
|
+
|
|
125
175
|
test("expose with missing --domain value exits 1", async () => {
|
|
126
176
|
const { code, stderr } = await runCli(["expose", "public", "--cloudflare", "--domain"]);
|
|
127
177
|
expect(code).toBe(1);
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
InvalidRedirectUriError,
|
|
7
|
+
approveClient,
|
|
8
|
+
getClient,
|
|
9
|
+
isValidRedirectUri,
|
|
10
|
+
listClientsByStatus,
|
|
11
|
+
registerClient,
|
|
12
|
+
requireRegisteredRedirectUri,
|
|
13
|
+
verifyClientSecret,
|
|
14
|
+
} from "../clients.ts";
|
|
15
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
16
|
+
|
|
17
|
+
function makeDb() {
|
|
18
|
+
const configDir = mkdtempSync(join(tmpdir(), "phub-clients-"));
|
|
19
|
+
const db = openHubDb(hubDbPath(configDir));
|
|
20
|
+
return {
|
|
21
|
+
db,
|
|
22
|
+
cleanup: () => {
|
|
23
|
+
db.close();
|
|
24
|
+
rmSync(configDir, { recursive: true, force: true });
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("registerClient", () => {
|
|
30
|
+
test("public client has no client_secret", () => {
|
|
31
|
+
const { db, cleanup } = makeDb();
|
|
32
|
+
try {
|
|
33
|
+
const r = registerClient(db, {
|
|
34
|
+
redirectUris: ["https://example.com/cb"],
|
|
35
|
+
scopes: ["vault.read"],
|
|
36
|
+
clientName: "test",
|
|
37
|
+
});
|
|
38
|
+
expect(r.clientSecret).toBeNull();
|
|
39
|
+
expect(r.client.clientSecretHash).toBeNull();
|
|
40
|
+
expect(r.client.clientId.length).toBeGreaterThan(0);
|
|
41
|
+
expect(r.client.redirectUris).toEqual(["https://example.com/cb"]);
|
|
42
|
+
expect(r.client.scopes).toEqual(["vault.read"]);
|
|
43
|
+
expect(r.client.clientName).toBe("test");
|
|
44
|
+
} finally {
|
|
45
|
+
cleanup();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("confidential client returns plaintext secret once and stores hash", () => {
|
|
50
|
+
const { db, cleanup } = makeDb();
|
|
51
|
+
try {
|
|
52
|
+
const r = registerClient(db, {
|
|
53
|
+
redirectUris: ["https://example.com/cb"],
|
|
54
|
+
confidential: true,
|
|
55
|
+
});
|
|
56
|
+
expect(r.clientSecret).not.toBeNull();
|
|
57
|
+
expect(r.clientSecret?.length).toBeGreaterThan(20);
|
|
58
|
+
// Hash is sha256 hex (64 chars).
|
|
59
|
+
expect(r.client.clientSecretHash).toMatch(/^[0-9a-f]{64}$/);
|
|
60
|
+
// The plaintext is not recoverable from the row.
|
|
61
|
+
const fetched = getClient(db, r.client.clientId);
|
|
62
|
+
expect(fetched?.clientSecretHash).toBe(r.client.clientSecretHash);
|
|
63
|
+
} finally {
|
|
64
|
+
cleanup();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("rejects empty redirect_uris", () => {
|
|
69
|
+
const { db, cleanup } = makeDb();
|
|
70
|
+
try {
|
|
71
|
+
expect(() => registerClient(db, { redirectUris: [] })).toThrow(/redirect_uri/);
|
|
72
|
+
} finally {
|
|
73
|
+
cleanup();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("rejects non-http(s) redirect_uri", () => {
|
|
78
|
+
const { db, cleanup } = makeDb();
|
|
79
|
+
try {
|
|
80
|
+
expect(() => registerClient(db, { redirectUris: ["javascript:alert(1)"] })).toThrow(
|
|
81
|
+
/invalid redirect_uri/,
|
|
82
|
+
);
|
|
83
|
+
expect(() => registerClient(db, { redirectUris: ["/relative/path"] })).toThrow(
|
|
84
|
+
/invalid redirect_uri/,
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
cleanup();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("getClient", () => {
|
|
93
|
+
test("returns null for unknown clientId", () => {
|
|
94
|
+
const { db, cleanup } = makeDb();
|
|
95
|
+
try {
|
|
96
|
+
expect(getClient(db, "nope")).toBeNull();
|
|
97
|
+
} finally {
|
|
98
|
+
cleanup();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("round-trips a registered client", () => {
|
|
103
|
+
const { db, cleanup } = makeDb();
|
|
104
|
+
try {
|
|
105
|
+
const r = registerClient(db, {
|
|
106
|
+
redirectUris: ["https://a.example/cb", "https://b.example/cb"],
|
|
107
|
+
scopes: ["vault.read", "vault.write"],
|
|
108
|
+
});
|
|
109
|
+
const fetched = getClient(db, r.client.clientId);
|
|
110
|
+
expect(fetched?.redirectUris).toEqual(["https://a.example/cb", "https://b.example/cb"]);
|
|
111
|
+
expect(fetched?.scopes).toEqual(["vault.read", "vault.write"]);
|
|
112
|
+
} finally {
|
|
113
|
+
cleanup();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("requireRegisteredRedirectUri", () => {
|
|
119
|
+
test("returns the matched URI on exact match", () => {
|
|
120
|
+
const { db, cleanup } = makeDb();
|
|
121
|
+
try {
|
|
122
|
+
const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
|
|
123
|
+
expect(requireRegisteredRedirectUri(r.client, "https://example.com/cb")).toBe(
|
|
124
|
+
"https://example.com/cb",
|
|
125
|
+
);
|
|
126
|
+
} finally {
|
|
127
|
+
cleanup();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("throws on prefix-only / loose match (open-redirect guard)", () => {
|
|
132
|
+
const { db, cleanup } = makeDb();
|
|
133
|
+
try {
|
|
134
|
+
const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
|
|
135
|
+
expect(() => requireRegisteredRedirectUri(r.client, "https://example.com/cb/extra")).toThrow(
|
|
136
|
+
InvalidRedirectUriError,
|
|
137
|
+
);
|
|
138
|
+
expect(() => requireRegisteredRedirectUri(r.client, "https://evil.com/cb")).toThrow(
|
|
139
|
+
InvalidRedirectUriError,
|
|
140
|
+
);
|
|
141
|
+
} finally {
|
|
142
|
+
cleanup();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("verifyClientSecret", () => {
|
|
148
|
+
test("matches the issued secret, rejects others", () => {
|
|
149
|
+
const { db, cleanup } = makeDb();
|
|
150
|
+
try {
|
|
151
|
+
const r = registerClient(db, {
|
|
152
|
+
redirectUris: ["https://example.com/cb"],
|
|
153
|
+
confidential: true,
|
|
154
|
+
});
|
|
155
|
+
expect(r.clientSecret).not.toBeNull();
|
|
156
|
+
expect(verifyClientSecret(r.client, r.clientSecret ?? "")).toBe(true);
|
|
157
|
+
expect(verifyClientSecret(r.client, "wrong")).toBe(false);
|
|
158
|
+
} finally {
|
|
159
|
+
cleanup();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("returns false for public clients regardless of presented secret", () => {
|
|
164
|
+
const { db, cleanup } = makeDb();
|
|
165
|
+
try {
|
|
166
|
+
const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
|
|
167
|
+
expect(verifyClientSecret(r.client, "anything")).toBe(false);
|
|
168
|
+
} finally {
|
|
169
|
+
cleanup();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("approval gate (#74)", () => {
|
|
175
|
+
test("registerClient defaults status to approved (direct callers)", () => {
|
|
176
|
+
const { db, cleanup } = makeDb();
|
|
177
|
+
try {
|
|
178
|
+
const r = registerClient(db, { redirectUris: ["https://example.com/cb"] });
|
|
179
|
+
expect(r.client.status).toBe("approved");
|
|
180
|
+
} finally {
|
|
181
|
+
cleanup();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("registerClient honors explicit status: pending (DCR path)", () => {
|
|
186
|
+
const { db, cleanup } = makeDb();
|
|
187
|
+
try {
|
|
188
|
+
const r = registerClient(db, {
|
|
189
|
+
redirectUris: ["https://example.com/cb"],
|
|
190
|
+
status: "pending",
|
|
191
|
+
});
|
|
192
|
+
expect(r.client.status).toBe("pending");
|
|
193
|
+
expect(getClient(db, r.client.clientId)?.status).toBe("pending");
|
|
194
|
+
} finally {
|
|
195
|
+
cleanup();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("approveClient promotes pending → approved and is idempotent", () => {
|
|
200
|
+
const { db, cleanup } = makeDb();
|
|
201
|
+
try {
|
|
202
|
+
const r = registerClient(db, {
|
|
203
|
+
redirectUris: ["https://example.com/cb"],
|
|
204
|
+
status: "pending",
|
|
205
|
+
});
|
|
206
|
+
expect(approveClient(db, r.client.clientId)).toBe(true);
|
|
207
|
+
expect(getClient(db, r.client.clientId)?.status).toBe("approved");
|
|
208
|
+
// Second call is a no-op but still returns true.
|
|
209
|
+
expect(approveClient(db, r.client.clientId)).toBe(true);
|
|
210
|
+
expect(getClient(db, r.client.clientId)?.status).toBe("approved");
|
|
211
|
+
} finally {
|
|
212
|
+
cleanup();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("approveClient returns false for unknown client", () => {
|
|
217
|
+
const { db, cleanup } = makeDb();
|
|
218
|
+
try {
|
|
219
|
+
expect(approveClient(db, "no-such-client")).toBe(false);
|
|
220
|
+
} finally {
|
|
221
|
+
cleanup();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("listClientsByStatus filters and orders by registered_at", () => {
|
|
226
|
+
const { db, cleanup } = makeDb();
|
|
227
|
+
try {
|
|
228
|
+
const a = registerClient(db, {
|
|
229
|
+
redirectUris: ["https://a.example/cb"],
|
|
230
|
+
status: "pending",
|
|
231
|
+
now: () => new Date("2026-01-01T00:00:00Z"),
|
|
232
|
+
});
|
|
233
|
+
const b = registerClient(db, {
|
|
234
|
+
redirectUris: ["https://b.example/cb"],
|
|
235
|
+
status: "approved",
|
|
236
|
+
now: () => new Date("2026-01-02T00:00:00Z"),
|
|
237
|
+
});
|
|
238
|
+
const c = registerClient(db, {
|
|
239
|
+
redirectUris: ["https://c.example/cb"],
|
|
240
|
+
status: "pending",
|
|
241
|
+
now: () => new Date("2026-01-03T00:00:00Z"),
|
|
242
|
+
});
|
|
243
|
+
const pending = listClientsByStatus(db, "pending").map((r) => r.clientId);
|
|
244
|
+
expect(pending).toEqual([a.client.clientId, c.client.clientId]);
|
|
245
|
+
const approved = listClientsByStatus(db, "approved").map((r) => r.clientId);
|
|
246
|
+
expect(approved).toEqual([b.client.clientId]);
|
|
247
|
+
} finally {
|
|
248
|
+
cleanup();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("isValidRedirectUri", () => {
|
|
254
|
+
test("accepts http and https", () => {
|
|
255
|
+
expect(isValidRedirectUri("http://localhost:3000/cb")).toBe(true);
|
|
256
|
+
expect(isValidRedirectUri("https://example.com/cb")).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
test("rejects javascript:, data:, relative paths, garbage", () => {
|
|
259
|
+
expect(isValidRedirectUri("javascript:alert(1)")).toBe(false);
|
|
260
|
+
expect(isValidRedirectUri("data:text/html,x")).toBe(false);
|
|
261
|
+
expect(isValidRedirectUri("/relative")).toBe(false);
|
|
262
|
+
expect(isValidRedirectUri("not a url")).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
type CloudflaredState,
|
|
7
7
|
CloudflaredStateError,
|
|
8
|
+
type CloudflaredTunnelRecord,
|
|
8
9
|
clearCloudflaredState,
|
|
10
|
+
findTunnelRecord,
|
|
11
|
+
listTunnelRecords,
|
|
9
12
|
readCloudflaredState,
|
|
13
|
+
withTunnelRecord,
|
|
14
|
+
withoutTunnelRecord,
|
|
10
15
|
writeCloudflaredState,
|
|
11
16
|
} from "../cloudflare/state.ts";
|
|
12
17
|
|
|
@@ -18,14 +23,18 @@ function makeTempPath(): { path: string; cleanup: () => void } {
|
|
|
18
23
|
};
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
const
|
|
22
|
-
version: 1,
|
|
26
|
+
const sampleRecord: CloudflaredTunnelRecord = {
|
|
23
27
|
pid: 12345,
|
|
24
28
|
tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
|
|
25
29
|
tunnelName: "parachute",
|
|
26
30
|
hostname: "vault.example.com",
|
|
27
31
|
startedAt: "2026-04-22T12:00:00.000Z",
|
|
28
|
-
configPath: "/home/x/.parachute/cloudflared/config.yml",
|
|
32
|
+
configPath: "/home/x/.parachute/cloudflared/parachute/config.yml",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const sample: CloudflaredState = {
|
|
36
|
+
version: 2,
|
|
37
|
+
tunnels: { parachute: sampleRecord },
|
|
29
38
|
};
|
|
30
39
|
|
|
31
40
|
describe("cloudflared state", () => {
|
|
@@ -63,23 +72,45 @@ describe("cloudflared state", () => {
|
|
|
63
72
|
test("throws on unsupported version", () => {
|
|
64
73
|
const { path, cleanup } = makeTempPath();
|
|
65
74
|
try {
|
|
66
|
-
writeFileSync(path, JSON.stringify({
|
|
75
|
+
writeFileSync(path, JSON.stringify({ version: 99, tunnels: {} }));
|
|
67
76
|
expect(() => readCloudflaredState(path)).toThrow(/unsupported version/);
|
|
68
77
|
} finally {
|
|
69
78
|
cleanup();
|
|
70
79
|
}
|
|
71
80
|
});
|
|
72
81
|
|
|
73
|
-
test("throws on non-positive pid", () => {
|
|
82
|
+
test("throws on non-positive pid in a tunnel record", () => {
|
|
74
83
|
const { path, cleanup } = makeTempPath();
|
|
75
84
|
try {
|
|
76
|
-
writeFileSync(
|
|
85
|
+
writeFileSync(
|
|
86
|
+
path,
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
version: 2,
|
|
89
|
+
tunnels: { parachute: { ...sampleRecord, pid: -1 } },
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
77
92
|
expect(() => readCloudflaredState(path)).toThrow(CloudflaredStateError);
|
|
78
93
|
} finally {
|
|
79
94
|
cleanup();
|
|
80
95
|
}
|
|
81
96
|
});
|
|
82
97
|
|
|
98
|
+
test("throws when tunnel record's name doesn't match its key", () => {
|
|
99
|
+
const { path, cleanup } = makeTempPath();
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(
|
|
102
|
+
path,
|
|
103
|
+
JSON.stringify({
|
|
104
|
+
version: 2,
|
|
105
|
+
tunnels: { parachute: { ...sampleRecord, tunnelName: "different" } },
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
expect(() => readCloudflaredState(path)).toThrow(/must equal its key/);
|
|
109
|
+
} finally {
|
|
110
|
+
cleanup();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
83
114
|
test("throws on malformed JSON", () => {
|
|
84
115
|
const { path, cleanup } = makeTempPath();
|
|
85
116
|
try {
|
|
@@ -89,4 +120,133 @@ describe("cloudflared state", () => {
|
|
|
89
120
|
cleanup();
|
|
90
121
|
}
|
|
91
122
|
});
|
|
123
|
+
|
|
124
|
+
test("migrates v1 single-record state to v2 on read", () => {
|
|
125
|
+
const { path, cleanup } = makeTempPath();
|
|
126
|
+
try {
|
|
127
|
+
// v1 — pre-#32 shape with the single record at the top level. cloudflared
|
|
128
|
+
// installs in the wild may still have this on disk; reading it must not
|
|
129
|
+
// explode and must yield the canonical v2 shape.
|
|
130
|
+
const legacy = {
|
|
131
|
+
version: 1,
|
|
132
|
+
pid: 12345,
|
|
133
|
+
tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
|
|
134
|
+
tunnelName: "parachute",
|
|
135
|
+
hostname: "vault.example.com",
|
|
136
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
137
|
+
configPath: "/home/x/.parachute/cloudflared/config.yml",
|
|
138
|
+
};
|
|
139
|
+
writeFileSync(path, JSON.stringify(legacy));
|
|
140
|
+
|
|
141
|
+
const state = readCloudflaredState(path);
|
|
142
|
+
expect(state).toEqual({
|
|
143
|
+
version: 2,
|
|
144
|
+
tunnels: {
|
|
145
|
+
parachute: {
|
|
146
|
+
pid: 12345,
|
|
147
|
+
tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
|
|
148
|
+
tunnelName: "parachute",
|
|
149
|
+
hostname: "vault.example.com",
|
|
150
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
151
|
+
configPath: "/home/x/.parachute/cloudflared/config.yml",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
} finally {
|
|
156
|
+
cleanup();
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("v1 migration is read-only until the next write", () => {
|
|
161
|
+
// The migration is silent on read but doesn't rewrite disk on its own —
|
|
162
|
+
// disk only flips when the next write commits. Mirrors how other state
|
|
163
|
+
// migrations in the repo behave; documents the contract.
|
|
164
|
+
const { path, cleanup } = makeTempPath();
|
|
165
|
+
try {
|
|
166
|
+
const legacy = {
|
|
167
|
+
version: 1,
|
|
168
|
+
pid: 12345,
|
|
169
|
+
tunnelUuid: "2c1a7c7e-1234-5678-9abc-def012345678",
|
|
170
|
+
tunnelName: "parachute",
|
|
171
|
+
hostname: "vault.example.com",
|
|
172
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
173
|
+
configPath: "/home/x/.parachute/cloudflared/config.yml",
|
|
174
|
+
};
|
|
175
|
+
writeFileSync(path, JSON.stringify(legacy));
|
|
176
|
+
readCloudflaredState(path); // returns v2 in memory
|
|
177
|
+
const onDisk = JSON.parse(readFileSync(path, "utf8"));
|
|
178
|
+
expect(onDisk.version).toBe(1);
|
|
179
|
+
|
|
180
|
+
// After a write, disk reflects v2.
|
|
181
|
+
const state = readCloudflaredState(path);
|
|
182
|
+
writeCloudflaredState(state as CloudflaredState, path);
|
|
183
|
+
const onDiskAfter = JSON.parse(readFileSync(path, "utf8"));
|
|
184
|
+
expect(onDiskAfter.version).toBe(2);
|
|
185
|
+
expect(onDiskAfter.tunnels.parachute.tunnelName).toBe("parachute");
|
|
186
|
+
} finally {
|
|
187
|
+
cleanup();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("cloudflared state — record helpers", () => {
|
|
193
|
+
const recordA: CloudflaredTunnelRecord = {
|
|
194
|
+
pid: 1001,
|
|
195
|
+
tunnelUuid: "aaaaaaaa-0000-0000-0000-000000000001",
|
|
196
|
+
tunnelName: "alpha",
|
|
197
|
+
hostname: "alpha.example.com",
|
|
198
|
+
startedAt: "2026-04-23T10:00:00.000Z",
|
|
199
|
+
configPath: "/tmp/alpha/config.yml",
|
|
200
|
+
};
|
|
201
|
+
const recordB: CloudflaredTunnelRecord = {
|
|
202
|
+
pid: 1002,
|
|
203
|
+
tunnelUuid: "bbbbbbbb-0000-0000-0000-000000000002",
|
|
204
|
+
tunnelName: "beta",
|
|
205
|
+
hostname: "beta.example.com",
|
|
206
|
+
startedAt: "2026-04-23T11:00:00.000Z",
|
|
207
|
+
configPath: "/tmp/beta/config.yml",
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
test("findTunnelRecord returns undefined for unknown name and the record for known name", () => {
|
|
211
|
+
const state: CloudflaredState = { version: 2, tunnels: { alpha: recordA } };
|
|
212
|
+
expect(findTunnelRecord(state, "alpha")).toEqual(recordA);
|
|
213
|
+
expect(findTunnelRecord(state, "beta")).toBeUndefined();
|
|
214
|
+
expect(findTunnelRecord(undefined, "alpha")).toBeUndefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("withTunnelRecord inserts into empty/undefined state", () => {
|
|
218
|
+
const next = withTunnelRecord(undefined, recordA);
|
|
219
|
+
expect(next).toEqual({ version: 2, tunnels: { alpha: recordA } });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("withTunnelRecord adds a second tunnel without disturbing the first", () => {
|
|
223
|
+
const initial = withTunnelRecord(undefined, recordA);
|
|
224
|
+
const next = withTunnelRecord(initial, recordB);
|
|
225
|
+
expect(next.tunnels).toEqual({ alpha: recordA, beta: recordB });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("withTunnelRecord replaces an existing record under the same name", () => {
|
|
229
|
+
const initial = withTunnelRecord(undefined, recordA);
|
|
230
|
+
const replaced: CloudflaredTunnelRecord = { ...recordA, pid: 9999 };
|
|
231
|
+
const next = withTunnelRecord(initial, replaced);
|
|
232
|
+
expect(next.tunnels.alpha).toEqual(replaced);
|
|
233
|
+
expect(Object.keys(next.tunnels)).toEqual(["alpha"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("withoutTunnelRecord drops the named tunnel and returns undefined when empty", () => {
|
|
237
|
+
const initial = withTunnelRecord(undefined, recordA);
|
|
238
|
+
expect(withoutTunnelRecord(initial, "alpha")).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("withoutTunnelRecord leaves other tunnels in place", () => {
|
|
242
|
+
const both = withTunnelRecord(withTunnelRecord(undefined, recordA), recordB);
|
|
243
|
+
const next = withoutTunnelRecord(both, "alpha");
|
|
244
|
+
expect(next).toEqual({ version: 2, tunnels: { beta: recordB } });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("listTunnelRecords returns sorted-by-name order", () => {
|
|
248
|
+
const both = withTunnelRecord(withTunnelRecord(undefined, recordB), recordA);
|
|
249
|
+
expect(listTunnelRecords(both).map((r) => r.tunnelName)).toEqual(["alpha", "beta"]);
|
|
250
|
+
expect(listTunnelRecords(undefined)).toEqual([]);
|
|
251
|
+
});
|
|
92
252
|
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CSRF_COOKIE_NAME,
|
|
4
|
+
CSRF_FIELD_NAME,
|
|
5
|
+
buildCsrfCookie,
|
|
6
|
+
ensureCsrfToken,
|
|
7
|
+
generateCsrfToken,
|
|
8
|
+
parseCsrfCookie,
|
|
9
|
+
renderCsrfHiddenInput,
|
|
10
|
+
verifyCsrfToken,
|
|
11
|
+
} from "../csrf.ts";
|
|
12
|
+
|
|
13
|
+
function reqWith(cookie: string | null, formToken?: string | null): Request {
|
|
14
|
+
const headers = new Headers();
|
|
15
|
+
if (cookie !== null) headers.set("cookie", cookie);
|
|
16
|
+
return new Request("https://hub.example/oauth/authorize", { headers });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("generateCsrfToken", () => {
|
|
20
|
+
test("returns a base64url string with > 32 chars of entropy", () => {
|
|
21
|
+
const token = generateCsrfToken();
|
|
22
|
+
expect(token.length).toBeGreaterThan(32);
|
|
23
|
+
expect(token).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("two calls produce distinct tokens", () => {
|
|
27
|
+
expect(generateCsrfToken()).not.toBe(generateCsrfToken());
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("buildCsrfCookie", () => {
|
|
32
|
+
test("emits the expected attributes", () => {
|
|
33
|
+
const v = buildCsrfCookie("abc");
|
|
34
|
+
expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
|
|
35
|
+
expect(v).toContain("HttpOnly");
|
|
36
|
+
expect(v).toContain("Secure");
|
|
37
|
+
expect(v).toContain("SameSite=Lax");
|
|
38
|
+
expect(v).toContain("Path=/");
|
|
39
|
+
expect(v).toContain("Max-Age=");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("parseCsrfCookie", () => {
|
|
44
|
+
test("extracts the token from a cookie header", () => {
|
|
45
|
+
expect(parseCsrfCookie(`${CSRF_COOKIE_NAME}=xyz`)).toBe("xyz");
|
|
46
|
+
expect(parseCsrfCookie(`other=foo; ${CSRF_COOKIE_NAME}=xyz; bar=baz`)).toBe("xyz");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("returns null when absent or empty", () => {
|
|
50
|
+
expect(parseCsrfCookie(null)).toBeNull();
|
|
51
|
+
expect(parseCsrfCookie("")).toBeNull();
|
|
52
|
+
expect(parseCsrfCookie("other=foo")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("ensureCsrfToken", () => {
|
|
57
|
+
test("mints a fresh cookie when none is present", () => {
|
|
58
|
+
const result = ensureCsrfToken(reqWith(null));
|
|
59
|
+
expect(result.token.length).toBeGreaterThan(32);
|
|
60
|
+
expect(result.setCookie).toContain(`${CSRF_COOKIE_NAME}=${result.token}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("reuses the existing cookie token without re-setting", () => {
|
|
64
|
+
const result = ensureCsrfToken(reqWith(`${CSRF_COOKIE_NAME}=existing-token`));
|
|
65
|
+
expect(result.token).toBe("existing-token");
|
|
66
|
+
expect(result.setCookie).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("mints fresh when the cookie is empty", () => {
|
|
70
|
+
const result = ensureCsrfToken(reqWith(`${CSRF_COOKIE_NAME}=`));
|
|
71
|
+
expect(result.token.length).toBeGreaterThan(0);
|
|
72
|
+
expect(result.setCookie).toBeDefined();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("verifyCsrfToken", () => {
|
|
77
|
+
test("returns true when cookie and form match", () => {
|
|
78
|
+
const token = "match-me";
|
|
79
|
+
const req = reqWith(`${CSRF_COOKIE_NAME}=${token}`);
|
|
80
|
+
expect(verifyCsrfToken(req, token)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns false when the form token differs", () => {
|
|
84
|
+
const req = reqWith(`${CSRF_COOKIE_NAME}=cookie-token`);
|
|
85
|
+
expect(verifyCsrfToken(req, "form-token")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("returns false when cookie token is missing", () => {
|
|
89
|
+
expect(verifyCsrfToken(reqWith(null), "form-token")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("returns false when form token is missing", () => {
|
|
93
|
+
const req = reqWith(`${CSRF_COOKIE_NAME}=cookie-token`);
|
|
94
|
+
expect(verifyCsrfToken(req, null)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns false when lengths differ (avoids timingSafeEqual throw)", () => {
|
|
98
|
+
const req = reqWith(`${CSRF_COOKIE_NAME}=abcd`);
|
|
99
|
+
expect(verifyCsrfToken(req, "abcdef")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("renderCsrfHiddenInput", () => {
|
|
104
|
+
test("renders an HTML hidden input with the field name", () => {
|
|
105
|
+
const html = renderCsrfHiddenInput("token-123");
|
|
106
|
+
expect(html).toContain(`name="${CSRF_FIELD_NAME}"`);
|
|
107
|
+
expect(html).toContain('value="token-123"');
|
|
108
|
+
expect(html).toContain('type="hidden"');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("escapes hostile token content into the value attribute", () => {
|
|
112
|
+
const html = renderCsrfHiddenInput(`"><script>alert(1)</script>`);
|
|
113
|
+
expect(html).not.toContain("<script>");
|
|
114
|
+
expect(html).toContain(""");
|
|
115
|
+
expect(html).toContain("<script");
|
|
116
|
+
});
|
|
117
|
+
});
|