crabbox/worker/test/http.test.ts
2026-05-03 23:27:39 -07:00

227 lines
7.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import coordinator, { isAuthorized } from "../src";
import {
authenticateRequest,
base64URL,
issueUserToken,
requestWithAuthContext,
} from "../src/auth";
import { requestOwner } from "../src/http";
import type { Env } from "../src/types";
describe("coordinator auth", () => {
it("denies requests when no shared token is configured", async () => {
const request = new Request("https://example.test/v1/pool");
await expect(isAuthorized(request, {})).resolves.toBe(false);
});
it("requires the configured bearer token", async () => {
const denied = new Request("https://example.test/v1/pool");
const allowed = new Request("https://example.test/v1/pool", {
headers: { authorization: "Bearer secret" },
});
await expect(isAuthorized(denied, { CRABBOX_SHARED_TOKEN: "secret" })).resolves.toBe(false);
await expect(isAuthorized(allowed, { CRABBOX_SHARED_TOKEN: "secret" })).resolves.toBe(true);
});
it("keeps shared bearer token non-admin and requires a separate admin token", async () => {
const env = {
CRABBOX_SHARED_TOKEN: "shared",
CRABBOX_ADMIN_TOKEN: "admin",
CRABBOX_DEFAULT_ORG: "openclaw",
};
const shared = await authenticateRequest(
new Request("https://example.test/v1/pool", {
headers: {
authorization: "Bearer shared",
"x-crabbox-owner": "operator@example.com",
"cf-access-authenticated-user-email": "spoof@example.com",
},
}),
env,
);
const admin = await authenticateRequest(
new Request("https://example.test/v1/pool", {
headers: { authorization: "Bearer admin", "x-crabbox-owner": "operator@example.com" },
}),
env,
);
expect(shared).toMatchObject({
authorized: true,
admin: false,
owner: "operator@example.com",
});
expect(admin).toMatchObject({
authorized: true,
admin: true,
owner: "operator@example.com",
});
});
it("uses Cloudflare Access identity only after verifying the Access JWT", async () => {
const { jwt, publicJwk } = await accessJwt({
kid: "access-test-kid",
aud: "access-aud",
iss: "https://team.example.cloudflareaccess.com",
email: "verified@example.com",
});
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ keys: [publicJwk] }), {
headers: { "content-type": "application/json" },
}),
);
try {
const auth = await authenticateRequest(
new Request("https://example.test/v1/whoami", {
headers: {
authorization: "Bearer shared",
"cf-access-authenticated-user-email": "spoof@example.com",
"cf-access-jwt-assertion": jwt,
"x-crabbox-owner": "operator@example.com",
},
}),
{
CRABBOX_SHARED_TOKEN: "shared",
CRABBOX_DEFAULT_ORG: "openclaw",
CRABBOX_ACCESS_TEAM_DOMAIN: "team.example.cloudflareaccess.com",
CRABBOX_ACCESS_AUD: "access-aud",
},
);
expect(auth).toMatchObject({
authorized: true,
admin: false,
owner: "verified@example.com",
});
expect(fetchMock).toHaveBeenCalledWith(
"https://team.example.cloudflareaccess.com/cdn-cgi/access/certs",
);
} finally {
fetchMock.mockRestore();
}
});
it("accepts signed GitHub user tokens without admin rights", async () => {
const env = { CRABBOX_SHARED_TOKEN: "shared", CRABBOX_DEFAULT_ORG: "openclaw" };
const token = await issueUserToken(env, {
owner: "friend@example.com",
org: "openclaw",
login: "friend",
});
const request = new Request("https://example.test/v1/whoami", {
headers: { authorization: `Bearer ${token}`, "x-crabbox-owner": "spoof@example.com" },
});
const auth = await authenticateRequest(request, env);
expect(auth).toMatchObject({
authorized: true,
admin: false,
auth: "github",
owner: "friend@example.com",
org: "openclaw",
login: "friend",
});
});
it("does not let caller-supplied Access identity override signed user token identity", () => {
const request = new Request("https://example.test/v1/whoami", {
headers: {
"cf-access-authenticated-user-email": "spoof@example.com",
"x-crabbox-owner": "spoof@example.com",
},
});
const next = requestWithAuthContext(request, {
authorized: true,
admin: false,
auth: "github",
owner: "friend@example.com",
org: "openclaw",
login: "friend",
});
expect(next.headers.get("cf-access-authenticated-user-email")).toBeNull();
expect(next.headers.get("cf-access-jwt-assertion")).toBeNull();
expect(requestOwner(next)).toBe("friend@example.com");
});
it("redirects browser portal auth routes to the configured public origin", async () => {
let fleetCalled = false;
const env = {
CRABBOX_PUBLIC_URL: "https://crabbox.openclaw.ai",
FLEET: {
idFromName: () => "default",
get: () => {
fleetCalled = true;
return { fetch: () => new Response("unexpected", { status: 599 }) };
},
},
} as unknown as Env;
const login = await coordinator.fetch(
new Request(
"https://crabbox-coordinator.steipete.workers.dev/portal/login?returnTo=%2Fportal%2Fleases%2Fcbx_1%2Fvnc",
),
env,
);
expect(login.status).toBe(302);
expect(login.headers.get("location")).toBe(
"https://crabbox.openclaw.ai/portal/login?returnTo=%2Fportal%2Fleases%2Fcbx_1%2Fvnc",
);
const logout = await coordinator.fetch(
new Request("https://crabbox-coordinator.steipete.workers.dev/portal/logout"),
env,
);
expect(logout.status).toBe(302);
expect(logout.headers.get("location")).toBe("https://crabbox.openclaw.ai/portal/logout");
expect(fleetCalled).toBe(false);
});
});
async function accessJwt(input: {
kid: string;
aud: string;
iss: string;
email: string;
}): Promise<{ jwt: string; publicJwk: JsonWebKey & { kid: string } }> {
const keyPair = (await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["sign", "verify"],
)) as CryptoKeyPair;
const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as JsonWebKey & {
kid: string;
};
publicJwk.kid = input.kid;
publicJwk.alg = "RS256";
publicJwk.use = "sig";
const now = Math.floor(Date.now() / 1000);
const header = base64URL(
new TextEncoder().encode(JSON.stringify({ alg: "RS256", kid: input.kid, typ: "JWT" })),
);
const payload = base64URL(
new TextEncoder().encode(
JSON.stringify({
aud: input.aud,
email: input.email,
exp: now + 300,
iat: now,
iss: input.iss,
sub: "access-subject",
}),
),
);
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
keyPair.privateKey,
new TextEncoder().encode(`${header}.${payload}`),
);
return { jwt: `${header}.${payload}.${base64URL(new Uint8Array(signature))}`, publicJwk };
}