Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions cloudflare/auth-flow.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { Miniflare } from "miniflare";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
Expand All @@ -19,6 +20,7 @@ let browser: Browser;
let origin: string;
const clients: Client[] = [];
const authSecret = "auth-flow-test-only-secret-with-at-least-32-characters";
const serviceToken = "auth-flow-test-only-service-token-with-enough-entropy";
const counterClient = await readFile(new URL("../examples/counter.artifact.tsx", import.meta.url), "utf8");
const counterServer = await readFile(new URL("../examples/counter.artifact.server.ts", import.meta.url), "utf8");

Expand All @@ -40,6 +42,7 @@ beforeAll(async () => {
BETTER_AUTH_SECRET: authSecret,
BETTER_AUTH_ALLOWED_DOMAINS: "example.test",
BETTER_AUTH_TRUSTED_IP_HEADER: "x-artifact-test-ip",
ARTIFACTS_SERVICE_TOKENS: JSON.stringify({ "integration-bot": createHash("sha256").update(serviceToken).digest("hex") }),
BETTER_AUTH_OIDC_PROVIDERS: JSON.stringify([{ providerId: "company", discoveryUrl: `${fixture.origin}/.well-known/openid-configuration`, clientId: "artifact-test", clientSecret: "fixture-only-not-real-secret", scopes: ["openid", "profile", "email"], pkce: true }]),
};
if (process.env.CELLD_AUTH_INTEGRATION === "1") {
Expand Down Expand Up @@ -498,3 +501,43 @@ test("ownership transfer preserves live resources and revokes former library acc
expect((await bobContext.request.post(`${origin}/api/library/move?workspace=test`, { data: { kind: "script", name: script, library: "private", privateKey: "alice" } })).status()).toBe(400);
} finally { await aliceContext.close(); await bobContext.close(); }
}, 180000);

test("a service token manages the team library over MCP and confers nothing outside it", async () => {
const bearer = { Authorization: `Bearer ${serviceToken}` };
const service = new Client({ name: "service-token", version: "1" });
clients.push(service);
await service.connect(new StreamableHTTPClientTransport(new URL(`${origin}/mcp?workspace=test&library=team`), { requestInit: { headers: bearer } }));
const written = await service.callTool({ name: "artifact_write", arguments: { name: "service-report", contents: counterClient, server: counterServer } });
expect(written.isError, JSON.stringify(written.content)).not.toBe(true);
const linked = await service.callTool({ name: "artifact_link", arguments: { kind: "artifact", name: "service-report", slug: "service-report" } });
expect(linked.isError, JSON.stringify(linked.content)).not.toBe(true);

// The service account has no personal library to fall back to.
for (const query of ["workspace=test", "workspace=test&library=private"]) {
const response = await fetch(`${origin}/mcp?${query}`, { headers: bearer });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: "Service tokens manage the team library; add library=team to the MCP URL" });
}
// Management, gallery and private URLs still require a signed-in person.
for (const path of ["/api/gallery?workspace=test&library=team", "/api/session", "/service-report"]) {
expect((await fetch(origin + path, { headers: bearer })).status).toBe(401);
}
expect((await fetch(`${origin}/api/tools?workspace=test&library=team`, { method: "POST", headers: { ...bearer, "Content-Type": "application/json" }, body: JSON.stringify({ name: "artifact_list", arguments: {} }) })).status).toBe(401);
const wrong = await fetch(`${origin}/mcp?workspace=test&library=team`, { headers: { Authorization: `Bearer ${serviceToken}-wrong` } });
expect(wrong.status).toBe(401);
expect(wrong.headers.get("www-authenticate")).toContain("resource_metadata=");

// A signed-in teammate finds the work in the team library and opens its private URL.
const context = await newBrowserContext();
try {
const page = await context.newPage();
await page.goto(`${origin}/?workspace=test`);
await login(page, "Bob");
await page.waitForURL(`${origin}/?workspace=test`);
const team = await (await context.request.get(`${origin}/api/gallery?workspace=test&library=team`)).json();
expect(team.artifacts.find((item: any) => item.name === "service-report")).toMatchObject({ url: `${origin}/service-report`, access: "private" });
const personal = await (await context.request.get(`${origin}/api/gallery?workspace=test`)).json();
expect(personal.artifacts.some((item: any) => item.name === "service-report")).toBe(false);
expect((await context.request.get(`${origin}/service-report`)).status()).toBe(200);
} finally { await context.close(); }
}, 120000);
35 changes: 35 additions & 0 deletions cloudflare/service-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { createHash } from "node:crypto";
import { servicePrincipal, serviceTokenDigests } from "./service-tokens";
import { ArtifactAuthConfigurationError } from "./better-auth";

const token = "service-token-test-only-value-with-enough-entropy";
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
const env = { ARTIFACTS_SERVICE_TOKENS: JSON.stringify({ "slack-bot": digest(token), "nightly-sync": digest("another-test-only-token") }) };

test("a configured service token resolves to its named principal only as a bearer credential", () => {
expect(servicePrincipal(`Bearer ${token}`, env)).toEqual({ name: "slack-bot" });
expect(servicePrincipal(`bearer ${token}`, env)).toEqual({ name: "slack-bot" });
expect(servicePrincipal("Bearer another-test-only-token", env)).toEqual({ name: "nightly-sync" });
for (const authorization of [null, undefined, "", token, `DPoP ${token}`, `Basic ${token}`, `Bearer ${token} extra`, `Bearer ${token}x`, `Bearer ${digest(token)}`]) {
expect(servicePrincipal(authorization, env)).toBeNull();
}
});

test("deployments without service tokens never resolve a service principal", () => {
expect(servicePrincipal(`Bearer ${token}`, {})).toBeNull();
expect(servicePrincipal(`Bearer ${token}`, { ARTIFACTS_SERVICE_TOKENS: "{}" })).toBeNull();
});

test("a malformed service token table is a configuration error rather than an empty table", () => {
const invalid = [
"{", "[]", "null", JSON.stringify({ "Slack Bot": digest(token) }), JSON.stringify({ "-bot": digest(token) }),
JSON.stringify({ bot: token }), JSON.stringify({ bot: digest(token).toUpperCase() }), JSON.stringify({ bot: 1 }),
JSON.stringify({ first: digest(token), second: digest(token) }),
];
for (const value of invalid) {
expect(() => serviceTokenDigests({ ARTIFACTS_SERVICE_TOKENS: value })).toThrow(ArtifactAuthConfigurationError);
// Even a request without a token reports the misconfiguration.
expect(() => servicePrincipal(null, { ARTIFACTS_SERVICE_TOKENS: value })).toThrow(ArtifactAuthConfigurationError);
}
});
46 changes: 46 additions & 0 deletions cloudflare/service-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { ArtifactAuthConfigurationError } from "./better-auth";

export type ServiceTokenEnvironment = { ARTIFACTS_SERVICE_TOKENS?: string };
/** An unattended MCP client admitted by deployment configuration rather than
* provider sign-in. It has no Better Auth user, session or personal library. */
export type ServicePrincipal = { name: string };
export const SERVICE_TOKEN_AUTHORITY = "service-token";

const SERVICE_NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
const SHA256_HEX = /^[0-9a-f]{64}$/;

/** Deployment variables are stored with the deployment record, so the table
* holds token digests only: `{"<service name>": "<lowercase hex SHA-256>"}`.
* A malformed table is a configuration error, never an empty table. */
export function serviceTokenDigests(env: ServiceTokenEnvironment): Map<string, Buffer> {
const digests = new Map<string, Buffer>();
if (!env.ARTIFACTS_SERVICE_TOKENS) return digests;
let table: unknown;
try { table = JSON.parse(env.ARTIFACTS_SERVICE_TOKENS); }
catch { throw new ArtifactAuthConfigurationError("ARTIFACTS_SERVICE_TOKENS must contain valid JSON"); }
if (!table || typeof table !== "object" || Array.isArray(table)) {
throw new ArtifactAuthConfigurationError("ARTIFACTS_SERVICE_TOKENS must be an object mapping service names to SHA-256 token digests");
}
const seen = new Set<string>();
for (const [name, digest] of Object.entries(table)) {
if (!SERVICE_NAME.test(name)) throw new ArtifactAuthConfigurationError(`ARTIFACTS_SERVICE_TOKENS has an invalid service name: ${JSON.stringify(name)}`);
if (typeof digest !== "string" || !SHA256_HEX.test(digest)) throw new ArtifactAuthConfigurationError(`ARTIFACTS_SERVICE_TOKENS entry ${name} must be a lowercase hex SHA-256 token digest`);
if (seen.has(digest)) throw new ArtifactAuthConfigurationError("ARTIFACTS_SERVICE_TOKENS digests must be unique");
seen.add(digest);
digests.set(name, Buffer.from(digest, "hex"));
}
return digests;
}

/** Resolve a `Bearer` service token. Null means "not a service token", so
* OAuth access-token verification proceeds unchanged. */
export function servicePrincipal(authorization: string | null | undefined, env: ServiceTokenEnvironment): ServicePrincipal | null {
const digests = serviceTokenDigests(env);
if (!digests.size) return null;
const token = /^Bearer (\S+)$/i.exec(authorization ?? "")?.[1];
if (!token) return null;
const supplied = createHash("sha256").update(token).digest();
for (const [name, digest] of digests) if (timingSafeEqual(supplied, digest)) return { name };
return null;
}
19 changes: 14 additions & 5 deletions cloudflare/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ManagedArtifactService } from "./managed-service";
// the MCP SDK constructs its top-level schemas (Zod 4.5.4).
import { authenticateBetterAuth, getArtifactAuth, ArtifactAuthConfigurationError, type BetterAuthEnvironment, type ArtifactUser } from "./better-auth";
import { handleCloudMcp } from "./mcp";
import { servicePrincipal, SERVICE_TOKEN_AUTHORITY, type ServiceTokenEnvironment } from "./service-tokens";
import { readRequestText, readToolBody } from "./http";
import { projectArchive } from "../src/project-archive";
import { ArtifactBackend } from "./backend";
Expand All @@ -31,7 +32,7 @@ import * as toolValidators from "../dist/cloudflare/tool-validators.js";
export { ArtifactLibrary, ArtifactBackend, ArtifactFiles, ArtifactLinks, ScriptLibrary, ScriptBackend, NativeApps };
// Deployed Durable Object exports retain their storage identities.
export { ArtifactLibrary as CanvasLibrary, ArtifactBackend as CanvasBackend, ArtifactFiles as CanvasFiles };
export type Env = AuthEnvironment & BetterAuthEnvironment & NativeEnvironment & {
export type Env = AuthEnvironment & BetterAuthEnvironment & ServiceTokenEnvironment & NativeEnvironment & {
NATIVE_APPS?: DurableObjectNamespace<NativeApps>;
LIBRARIES: DurableObjectNamespace<ArtifactLibrary>;
BACKENDS: DurableObjectNamespace<ArtifactBackend>;
Expand Down Expand Up @@ -96,19 +97,27 @@ app.use("*", async (c, next) => {
const origin = c.req.header("Origin");
if (origin && origin !== url.origin) return c.json({ error: "Origin is not allowed" }, 403);
if (c.env.AUTH_MODE && !["access", "better-auth"].includes(c.env.AUTH_MODE)) return c.json({ error: "AUTH_MODE must be access or better-auth" }, 503);
const user = c.env.AUTH_MODE === "better-auth" ? await authenticateBetterAuth(c.req.raw, c.env) : null;
// Service tokens authenticate MCP management only, like OAuth access tokens;
// private URLs and browser routes still require a signed-in person.
const serviceAccount = c.env.AUTH_MODE === "better-auth" && url.pathname === "/mcp" ? servicePrincipal(c.req.header("Authorization"), c.env) : null;
const user = c.env.AUTH_MODE === "better-auth" && !serviceAccount ? await authenticateBetterAuth(c.req.raw, c.env) : null;
if (user instanceof Response) return user;
const identity = user ? { subject: user.id, authority: "better-auth" } : await authenticate(c.req.raw, c.env);
const identity = serviceAccount ? { subject: serviceAccount.name, authority: SERVICE_TOKEN_AUTHORITY }
: user ? { subject: user.id, authority: "better-auth" } : await authenticate(c.req.raw, c.env);
if (identity instanceof Response) return identity;
c.set("user", user);
const workspace = (url.searchParams.get("workspace") ?? c.env.DEFAULT_WORKSPACE ?? "default").trim();
if (!workspace || workspace.includes("\0") || new TextEncoder().encode(workspace).byteLength > 4096) return c.json({ error: "Invalid workspace" }, 400);
const libraryScope = url.searchParams.get("library") ?? "private";
if (libraryScope !== "private" && libraryScope !== "team") return c.json({ error: "Library must be private or team" }, 400);
// Nobody can open a service account's personal library, so it has none: it
// manages the team library, and a "private" move destination resolves there
// too rather than stranding an item out of every person's reach.
if (serviceAccount && libraryScope !== "team") return c.json({ error: "Service tokens manage the team library; add library=team to the MCP URL" }, 403);
// The configured auth mode controls membership of this deployment's team. Private storage is
// selected only from the verified subject, never a caller-supplied user ID.
const privateKey = user
? JSON.stringify(["private", "better-auth", user.id])
const privateKey = serviceAccount ? "team"
: user ? JSON.stringify(["private", "better-auth", user.id])
: JSON.stringify(["private", c.env.ACCESS_TEAM_DOMAIN ?? "local", identity.subject]);
const libraryKey = libraryScope === "team" ? "team" : privateKey;
c.set("libraryScope", libraryScope);
Expand Down
Loading
Loading