openclaw-cloudflare 0.1.0
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/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/changeset-check.yml +25 -0
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/release.yml +39 -0
- package/README.md +142 -0
- package/openclaw.plugin.json +40 -0
- package/package.json +50 -0
- package/src/index.test.ts +395 -0
- package/src/index.ts +107 -0
- package/src/tunnel/access.test.ts +280 -0
- package/src/tunnel/access.ts +210 -0
- package/src/tunnel/cloudflared.test.ts +176 -0
- package/src/tunnel/cloudflared.ts +209 -0
- package/src/tunnel/exposure.test.ts +112 -0
- package/src/tunnel/exposure.ts +44 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createCloudflareAccessVerifier } from "./access.js";
|
|
4
|
+
|
|
5
|
+
// Generate a test RSA keypair for signing JWTs
|
|
6
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
|
|
7
|
+
modulusLength: 2048,
|
|
8
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
9
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Export as JWK for the mock JWKS endpoint
|
|
13
|
+
const publicJwk = crypto.createPublicKey(publicKey).export({ format: "jwk" }) as {
|
|
14
|
+
kty: string;
|
|
15
|
+
n: string;
|
|
16
|
+
e: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TEST_KID = "test-key-1";
|
|
20
|
+
const TEST_TEAM_DOMAIN = "myteam";
|
|
21
|
+
const TEST_ISSUER = `https://${TEST_TEAM_DOMAIN}.cloudflareaccess.com`;
|
|
22
|
+
const TEST_AUDIENCE = "test-aud-tag";
|
|
23
|
+
|
|
24
|
+
function base64UrlEncode(data: Buffer | string): string {
|
|
25
|
+
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
26
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function createTestJwt(opts: {
|
|
30
|
+
email?: string;
|
|
31
|
+
iss?: string;
|
|
32
|
+
aud?: string | string[];
|
|
33
|
+
exp?: number;
|
|
34
|
+
kid?: string;
|
|
35
|
+
}): Promise<string> {
|
|
36
|
+
const header = {
|
|
37
|
+
alg: "RS256",
|
|
38
|
+
typ: "JWT",
|
|
39
|
+
kid: opts.kid ?? TEST_KID,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const payload = {
|
|
43
|
+
email: opts.email ?? "user@example.com",
|
|
44
|
+
sub: "user-123",
|
|
45
|
+
iss: opts.iss ?? TEST_ISSUER,
|
|
46
|
+
aud: opts.aud ?? TEST_AUDIENCE,
|
|
47
|
+
exp: opts.exp ?? Math.floor(Date.now() / 1000) + 3600,
|
|
48
|
+
iat: Math.floor(Date.now() / 1000),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
52
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
53
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
54
|
+
|
|
55
|
+
const sign = crypto.createSign("RSA-SHA256");
|
|
56
|
+
sign.update(signingInput);
|
|
57
|
+
const signature = sign.sign(privateKey);
|
|
58
|
+
|
|
59
|
+
return `${signingInput}.${base64UrlEncode(signature)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createMockFetch(): typeof globalThis.fetch {
|
|
63
|
+
return vi.fn(async (url: string | URL | Request) => {
|
|
64
|
+
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
65
|
+
if (urlStr.includes("/cdn-cgi/access/certs")) {
|
|
66
|
+
return new Response(
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
keys: [
|
|
69
|
+
{
|
|
70
|
+
kty: publicJwk.kty,
|
|
71
|
+
kid: TEST_KID,
|
|
72
|
+
alg: "RS256",
|
|
73
|
+
use: "sig",
|
|
74
|
+
n: publicJwk.n,
|
|
75
|
+
e: publicJwk.e,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
{ status: 200 },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return new Response("Not Found", { status: 404 });
|
|
83
|
+
}) as typeof globalThis.fetch;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("cloudflare-access verifier", () => {
|
|
87
|
+
it("verifies a valid JWT and returns user email", async () => {
|
|
88
|
+
const verifier = createCloudflareAccessVerifier({
|
|
89
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
90
|
+
audience: TEST_AUDIENCE,
|
|
91
|
+
fetchFn: createMockFetch(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const token = await createTestJwt({});
|
|
95
|
+
const result = await verifier.verify(token);
|
|
96
|
+
|
|
97
|
+
expect(result).not.toBeNull();
|
|
98
|
+
expect(result!.email).toBe("user@example.com");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rejects expired JWT", async () => {
|
|
102
|
+
const verifier = createCloudflareAccessVerifier({
|
|
103
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
104
|
+
audience: TEST_AUDIENCE,
|
|
105
|
+
fetchFn: createMockFetch(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const token = await createTestJwt({
|
|
109
|
+
exp: Math.floor(Date.now() / 1000) - 3600,
|
|
110
|
+
});
|
|
111
|
+
const result = await verifier.verify(token);
|
|
112
|
+
|
|
113
|
+
expect(result).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects JWT with wrong issuer", async () => {
|
|
117
|
+
const verifier = createCloudflareAccessVerifier({
|
|
118
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
119
|
+
audience: TEST_AUDIENCE,
|
|
120
|
+
fetchFn: createMockFetch(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const token = await createTestJwt({
|
|
124
|
+
iss: "https://evil.cloudflareaccess.com",
|
|
125
|
+
});
|
|
126
|
+
const result = await verifier.verify(token);
|
|
127
|
+
|
|
128
|
+
expect(result).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects JWT with wrong audience when audience is configured", async () => {
|
|
132
|
+
const verifier = createCloudflareAccessVerifier({
|
|
133
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
134
|
+
audience: TEST_AUDIENCE,
|
|
135
|
+
fetchFn: createMockFetch(),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const token = await createTestJwt({
|
|
139
|
+
aud: "wrong-audience",
|
|
140
|
+
});
|
|
141
|
+
const result = await verifier.verify(token);
|
|
142
|
+
|
|
143
|
+
expect(result).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("accepts any audience when audience is not configured", async () => {
|
|
147
|
+
const verifier = createCloudflareAccessVerifier({
|
|
148
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
149
|
+
fetchFn: createMockFetch(),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const token = await createTestJwt({
|
|
153
|
+
aud: "any-audience",
|
|
154
|
+
});
|
|
155
|
+
const result = await verifier.verify(token);
|
|
156
|
+
|
|
157
|
+
expect(result).not.toBeNull();
|
|
158
|
+
expect(result!.email).toBe("user@example.com");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("rejects malformed token", async () => {
|
|
162
|
+
const verifier = createCloudflareAccessVerifier({
|
|
163
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
164
|
+
fetchFn: createMockFetch(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await verifier.verify("not.a.valid.jwt");
|
|
168
|
+
expect(result).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("rejects token with unknown kid", async () => {
|
|
172
|
+
const verifier = createCloudflareAccessVerifier({
|
|
173
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
174
|
+
fetchFn: createMockFetch(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const token = await createTestJwt({ kid: "unknown-key-id" });
|
|
178
|
+
const result = await verifier.verify(token);
|
|
179
|
+
|
|
180
|
+
expect(result).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rejects JWT with tampered signature", async () => {
|
|
184
|
+
const verifier = createCloudflareAccessVerifier({
|
|
185
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
186
|
+
audience: TEST_AUDIENCE,
|
|
187
|
+
fetchFn: createMockFetch(),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const token = await createTestJwt({});
|
|
191
|
+
// Tamper with the signature
|
|
192
|
+
const parts = token.split(".");
|
|
193
|
+
parts[2] = base64UrlEncode(crypto.randomBytes(256));
|
|
194
|
+
const tampered = parts.join(".");
|
|
195
|
+
|
|
196
|
+
const result = await verifier.verify(tampered);
|
|
197
|
+
expect(result).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("rejects JWT with unsupported algorithm", async () => {
|
|
201
|
+
const verifier = createCloudflareAccessVerifier({
|
|
202
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
203
|
+
audience: TEST_AUDIENCE,
|
|
204
|
+
fetchFn: createMockFetch(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Create a token with alg: "none"
|
|
208
|
+
const header = { alg: "none", typ: "JWT", kid: TEST_KID };
|
|
209
|
+
const payload = {
|
|
210
|
+
email: "user@example.com",
|
|
211
|
+
sub: "user-123",
|
|
212
|
+
iss: TEST_ISSUER,
|
|
213
|
+
aud: TEST_AUDIENCE,
|
|
214
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
215
|
+
iat: Math.floor(Date.now() / 1000),
|
|
216
|
+
};
|
|
217
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
218
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
219
|
+
const token = `${headerB64}.${payloadB64}.${base64UrlEncode("fake")}`;
|
|
220
|
+
|
|
221
|
+
const result = await verifier.verify(token);
|
|
222
|
+
expect(result).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("rejects JWT with tampered payload but original signature", async () => {
|
|
226
|
+
const verifier = createCloudflareAccessVerifier({
|
|
227
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
228
|
+
audience: TEST_AUDIENCE,
|
|
229
|
+
fetchFn: createMockFetch(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const token = await createTestJwt({});
|
|
233
|
+
const parts = token.split(".");
|
|
234
|
+
|
|
235
|
+
// Tamper with the payload (change email) but keep original signature
|
|
236
|
+
const tamperedPayload = {
|
|
237
|
+
email: "attacker@evil.com",
|
|
238
|
+
sub: "user-123",
|
|
239
|
+
iss: TEST_ISSUER,
|
|
240
|
+
aud: TEST_AUDIENCE,
|
|
241
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
242
|
+
iat: Math.floor(Date.now() / 1000),
|
|
243
|
+
};
|
|
244
|
+
parts[1] = base64UrlEncode(JSON.stringify(tamperedPayload));
|
|
245
|
+
const tampered = parts.join(".");
|
|
246
|
+
|
|
247
|
+
const result = await verifier.verify(tampered);
|
|
248
|
+
expect(result).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("rejects JWT with no email claim", async () => {
|
|
252
|
+
const verifier = createCloudflareAccessVerifier({
|
|
253
|
+
teamDomain: TEST_TEAM_DOMAIN,
|
|
254
|
+
fetchFn: createMockFetch(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Create a token with no email
|
|
258
|
+
const header = {
|
|
259
|
+
alg: "RS256",
|
|
260
|
+
typ: "JWT",
|
|
261
|
+
kid: TEST_KID,
|
|
262
|
+
};
|
|
263
|
+
const payload = {
|
|
264
|
+
sub: "service-token",
|
|
265
|
+
iss: TEST_ISSUER,
|
|
266
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
267
|
+
iat: Math.floor(Date.now() / 1000),
|
|
268
|
+
};
|
|
269
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
270
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
271
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
272
|
+
const sign = crypto.createSign("RSA-SHA256");
|
|
273
|
+
sign.update(signingInput);
|
|
274
|
+
const signature = sign.sign(privateKey);
|
|
275
|
+
const token = `${signingInput}.${base64UrlEncode(signature)}`;
|
|
276
|
+
|
|
277
|
+
const result = await verifier.verify(token);
|
|
278
|
+
expect(result).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type CloudflareAccessUser = {
|
|
4
|
+
email: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type CloudflareAccessVerifier = {
|
|
8
|
+
verify(token: string): Promise<CloudflareAccessUser | null>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type JwksKey = {
|
|
12
|
+
kty: string;
|
|
13
|
+
kid: string;
|
|
14
|
+
alg?: string;
|
|
15
|
+
n?: string;
|
|
16
|
+
e?: string;
|
|
17
|
+
crv?: string;
|
|
18
|
+
x?: string;
|
|
19
|
+
y?: string;
|
|
20
|
+
use?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type JwksResponse = {
|
|
24
|
+
keys: JwksKey[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type JwtHeader = {
|
|
28
|
+
alg: string;
|
|
29
|
+
kid?: string;
|
|
30
|
+
typ?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type JwtPayload = {
|
|
34
|
+
email?: string;
|
|
35
|
+
sub?: string;
|
|
36
|
+
iss?: string;
|
|
37
|
+
aud?: string | string[];
|
|
38
|
+
exp?: number;
|
|
39
|
+
iat?: number;
|
|
40
|
+
[key: string]: unknown;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const JWKS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
44
|
+
|
|
45
|
+
function base64UrlDecode(input: string): Buffer {
|
|
46
|
+
// Replace URL-safe chars and add padding
|
|
47
|
+
const padded = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
48
|
+
const paddedLength = padded.length + ((4 - (padded.length % 4)) % 4);
|
|
49
|
+
return Buffer.from(padded.padEnd(paddedLength, "="), "base64");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function decodeJwtPart<T>(part: string): T {
|
|
53
|
+
return JSON.parse(base64UrlDecode(part).toString("utf8")) as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function jwkToCryptoKeyAlgorithm(jwk: JwksKey): RsaHashedImportParams | EcKeyImportParams {
|
|
57
|
+
if (jwk.kty === "RSA") {
|
|
58
|
+
return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
|
|
59
|
+
}
|
|
60
|
+
if (jwk.kty === "EC") {
|
|
61
|
+
const namedCurve = jwk.crv ?? "P-256";
|
|
62
|
+
return { name: "ECDSA", namedCurve };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function verifyAlgorithmForKey(jwk: JwksKey): AlgorithmIdentifier | RsaPssParams | EcdsaParams {
|
|
68
|
+
if (jwk.kty === "RSA") {
|
|
69
|
+
return { name: "RSASSA-PKCS1-v1_5" };
|
|
70
|
+
}
|
|
71
|
+
if (jwk.kty === "EC") {
|
|
72
|
+
return { name: "ECDSA", hash: "SHA-256" };
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Unsupported JWK key type: ${jwk.kty}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function importJwk(jwk: JwksKey): Promise<crypto.webcrypto.CryptoKey> {
|
|
78
|
+
const algorithm = jwkToCryptoKeyAlgorithm(jwk);
|
|
79
|
+
// Only pass the fields relevant for key import
|
|
80
|
+
const keyData = {
|
|
81
|
+
kty: jwk.kty,
|
|
82
|
+
alg: jwk.alg,
|
|
83
|
+
use: jwk.use,
|
|
84
|
+
...(jwk.kty === "RSA" ? { n: jwk.n, e: jwk.e } : {}),
|
|
85
|
+
...(jwk.kty === "EC" ? { crv: jwk.crv, x: jwk.x, y: jwk.y } : {}),
|
|
86
|
+
};
|
|
87
|
+
return await crypto.subtle.importKey("jwk", keyData, algorithm, false, ["verify"]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create a Cloudflare Access JWT verifier.
|
|
92
|
+
*
|
|
93
|
+
* Fetches JWKS from `https://<teamDomain>.cloudflareaccess.com/cdn-cgi/access/certs`
|
|
94
|
+
* and verifies JWTs using Node's built-in WebCrypto API.
|
|
95
|
+
*/
|
|
96
|
+
export function createCloudflareAccessVerifier(opts: {
|
|
97
|
+
teamDomain: string;
|
|
98
|
+
audience?: string;
|
|
99
|
+
/** Override fetch for testing. */
|
|
100
|
+
fetchFn?: typeof globalThis.fetch;
|
|
101
|
+
}): CloudflareAccessVerifier {
|
|
102
|
+
const issuer = `https://${opts.teamDomain}.cloudflareaccess.com`;
|
|
103
|
+
const jwksUrl = `${issuer}/cdn-cgi/access/certs`;
|
|
104
|
+
const audience = opts.audience;
|
|
105
|
+
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
106
|
+
|
|
107
|
+
let cachedKeys: Map<string, JwksKey> | null = null;
|
|
108
|
+
let cachedAt = 0;
|
|
109
|
+
|
|
110
|
+
async function fetchJwks(): Promise<Map<string, JwksKey>> {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
if (cachedKeys && now - cachedAt < JWKS_CACHE_TTL_MS) {
|
|
113
|
+
return cachedKeys;
|
|
114
|
+
}
|
|
115
|
+
const res = await fetchFn(jwksUrl);
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${res.status}`);
|
|
118
|
+
}
|
|
119
|
+
const body = (await res.json()) as JwksResponse;
|
|
120
|
+
const keyMap = new Map<string, JwksKey>();
|
|
121
|
+
for (const key of body.keys ?? []) {
|
|
122
|
+
if (key.kid) {
|
|
123
|
+
keyMap.set(key.kid, key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
cachedKeys = keyMap;
|
|
127
|
+
cachedAt = now;
|
|
128
|
+
return keyMap;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function verify(token: string): Promise<CloudflareAccessUser | null> {
|
|
132
|
+
try {
|
|
133
|
+
const parts = token.split(".");
|
|
134
|
+
if (parts.length !== 3) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const header = decodeJwtPart<JwtHeader>(parts[0]);
|
|
139
|
+
const payload = decodeJwtPart<JwtPayload>(parts[1]);
|
|
140
|
+
|
|
141
|
+
// Validate issuer
|
|
142
|
+
if (payload.iss !== issuer) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate expiry
|
|
147
|
+
if (typeof payload.exp === "number" && payload.exp < Date.now() / 1000) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Validate audience (if configured)
|
|
152
|
+
if (audience) {
|
|
153
|
+
const aud = payload.aud;
|
|
154
|
+
const audMatch = Array.isArray(aud) ? aud.includes(audience) : aud === audience;
|
|
155
|
+
if (!audMatch) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Validate algorithm — only RS256 and ES256 are supported
|
|
161
|
+
const SUPPORTED_ALGS = new Set(["RS256", "ES256"]);
|
|
162
|
+
if (!SUPPORTED_ALGS.has(header.alg)) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Find the signing key
|
|
167
|
+
let keys = await fetchJwks();
|
|
168
|
+
let jwk = header.kid ? keys.get(header.kid) : undefined;
|
|
169
|
+
|
|
170
|
+
// If key not found, refresh JWKS (key rotation)
|
|
171
|
+
if (!jwk && header.kid) {
|
|
172
|
+
cachedKeys = null;
|
|
173
|
+
keys = await fetchJwks();
|
|
174
|
+
jwk = keys.get(header.kid);
|
|
175
|
+
}
|
|
176
|
+
if (!jwk) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Verify algorithm matches key type
|
|
181
|
+
const expectedAlg = jwk.kty === "RSA" ? "RS256" : jwk.kty === "EC" ? "ES256" : null;
|
|
182
|
+
if (header.alg !== expectedAlg) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Verify signature using WebCrypto
|
|
187
|
+
const cryptoKey = await importJwk(jwk);
|
|
188
|
+
const signatureInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
|
|
189
|
+
const signature = new Uint8Array(base64UrlDecode(parts[2]));
|
|
190
|
+
const algorithm = verifyAlgorithmForKey(jwk);
|
|
191
|
+
|
|
192
|
+
const valid = await crypto.subtle.verify(algorithm, cryptoKey, signature, signatureInput);
|
|
193
|
+
|
|
194
|
+
if (!valid) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const email = typeof payload.email === "string" ? payload.email : undefined;
|
|
199
|
+
if (!email) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { email };
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { verify };
|
|
210
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
// Mock spawn
|
|
6
|
+
const spawnMock = vi.fn();
|
|
7
|
+
vi.mock("node:child_process", () => ({
|
|
8
|
+
execFile: vi.fn(),
|
|
9
|
+
spawn: (...args: unknown[]) => spawnMock(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// Mock existsSync
|
|
13
|
+
const existsSyncMock = vi.fn<(p: string) => boolean>(() => true);
|
|
14
|
+
vi.mock("node:fs", () => ({
|
|
15
|
+
existsSync: (p: string) => existsSyncMock(p),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Shared exec mock for findCloudflaredBinary
|
|
19
|
+
const execMock = vi.fn();
|
|
20
|
+
|
|
21
|
+
describe("findCloudflaredBinary", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.resetModules();
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
delete process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns environment override when set", async () => {
|
|
29
|
+
process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/custom/cloudflared";
|
|
30
|
+
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
31
|
+
const result = await findCloudflaredBinary(execMock);
|
|
32
|
+
expect(result).toBe("/custom/cloudflared");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("finds cloudflared via which", async () => {
|
|
36
|
+
execMock.mockImplementation((cmd: string, _args: string[]) => {
|
|
37
|
+
if (cmd === "which") {
|
|
38
|
+
return Promise.resolve({ stdout: "/usr/local/bin/cloudflared\n", stderr: "" });
|
|
39
|
+
}
|
|
40
|
+
// --version check
|
|
41
|
+
return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
|
|
42
|
+
});
|
|
43
|
+
existsSyncMock.mockReturnValue(true);
|
|
44
|
+
|
|
45
|
+
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
46
|
+
const result = await findCloudflaredBinary(execMock);
|
|
47
|
+
expect(result).toBe("/usr/local/bin/cloudflared");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to known paths when which fails", async () => {
|
|
51
|
+
execMock.mockImplementation((cmd: string, _args: string[]) => {
|
|
52
|
+
if (cmd === "which") {
|
|
53
|
+
return Promise.reject(new Error("not found"));
|
|
54
|
+
}
|
|
55
|
+
// --version check for known path
|
|
56
|
+
return Promise.resolve({ stdout: "cloudflared version 2024.1.0\n", stderr: "" });
|
|
57
|
+
});
|
|
58
|
+
existsSyncMock.mockImplementation((p: string) => p === "/usr/local/bin/cloudflared");
|
|
59
|
+
|
|
60
|
+
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
61
|
+
const result = await findCloudflaredBinary(execMock);
|
|
62
|
+
expect(result).toBe("/usr/local/bin/cloudflared");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns null when binary is not found", async () => {
|
|
66
|
+
execMock.mockRejectedValue(new Error("not found"));
|
|
67
|
+
existsSyncMock.mockReturnValue(false);
|
|
68
|
+
|
|
69
|
+
const { findCloudflaredBinary } = await import("./cloudflared.js");
|
|
70
|
+
const result = await findCloudflaredBinary(execMock);
|
|
71
|
+
expect(result).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("startCloudflaredTunnel", () => {
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
vi.resetModules();
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
delete process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function createMockProcess(): ChildProcess & {
|
|
83
|
+
_emit: (event: string, ...args: unknown[]) => void;
|
|
84
|
+
_emitStderr: (data: string) => void;
|
|
85
|
+
} {
|
|
86
|
+
const events: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
87
|
+
const stdoutEvents: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
88
|
+
const stderrEvents: Record<string, Array<(...args: unknown[]) => void>> = {};
|
|
89
|
+
|
|
90
|
+
const mockStdout = {
|
|
91
|
+
setEncoding: vi.fn(),
|
|
92
|
+
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
93
|
+
stdoutEvents[event] = stdoutEvents[event] ?? [];
|
|
94
|
+
stdoutEvents[event].push(cb);
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
const mockStderr = {
|
|
98
|
+
setEncoding: vi.fn(),
|
|
99
|
+
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
100
|
+
stderrEvents[event] = stderrEvents[event] ?? [];
|
|
101
|
+
stderrEvents[event].push(cb);
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
pid: 12345,
|
|
107
|
+
killed: false,
|
|
108
|
+
stdout: mockStdout as unknown as Readable,
|
|
109
|
+
stderr: mockStderr as unknown as Readable,
|
|
110
|
+
kill: vi.fn(),
|
|
111
|
+
on: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
112
|
+
events[event] = events[event] ?? [];
|
|
113
|
+
events[event].push(cb);
|
|
114
|
+
}),
|
|
115
|
+
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
|
|
116
|
+
events[event] = events[event] ?? [];
|
|
117
|
+
events[event].push(cb);
|
|
118
|
+
}),
|
|
119
|
+
_emit: (event: string, ...args: unknown[]) => {
|
|
120
|
+
for (const cb of events[event] ?? []) {
|
|
121
|
+
cb(...args);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
_emitStderr: (data: string) => {
|
|
125
|
+
for (const cb of stderrEvents.data ?? []) {
|
|
126
|
+
cb(data);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
} as unknown as ChildProcess & {
|
|
130
|
+
_emit: (event: string, ...args: unknown[]) => void;
|
|
131
|
+
_emitStderr: (data: string) => void;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it("starts tunnel and parses connector ID", async () => {
|
|
136
|
+
const mockChild = createMockProcess();
|
|
137
|
+
|
|
138
|
+
spawnMock.mockReturnValue(mockChild);
|
|
139
|
+
process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/usr/local/bin/cloudflared";
|
|
140
|
+
|
|
141
|
+
const { startCloudflaredTunnel } = await import("./cloudflared.js");
|
|
142
|
+
|
|
143
|
+
const tunnelPromise = startCloudflaredTunnel({
|
|
144
|
+
token: "test-token",
|
|
145
|
+
timeoutMs: 5000,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Simulate cloudflared registering a connection
|
|
149
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
150
|
+
mockChild._emitStderr("INF Registered tunnel connection connectorID=abc123-def456");
|
|
151
|
+
|
|
152
|
+
const tunnel = await tunnelPromise;
|
|
153
|
+
expect(tunnel.connectorId).toBe("abc123-def456");
|
|
154
|
+
expect(tunnel.pid).toBe(12345);
|
|
155
|
+
expect(typeof tunnel.stop).toBe("function");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("throws when tunnel exits before registering", async () => {
|
|
159
|
+
const mockChild = createMockProcess();
|
|
160
|
+
|
|
161
|
+
spawnMock.mockReturnValue(mockChild);
|
|
162
|
+
process.env.OPENCLAW_TEST_CLOUDFLARED_BINARY = "/usr/local/bin/cloudflared";
|
|
163
|
+
|
|
164
|
+
const { startCloudflaredTunnel } = await import("./cloudflared.js");
|
|
165
|
+
|
|
166
|
+
const tunnelPromise = startCloudflaredTunnel({
|
|
167
|
+
token: "bad-token",
|
|
168
|
+
timeoutMs: 5000,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
172
|
+
mockChild._emit("exit", 1, null);
|
|
173
|
+
|
|
174
|
+
await expect(tunnelPromise).rejects.toThrow(/cloudflared exited/);
|
|
175
|
+
});
|
|
176
|
+
});
|