From b6d0f4be683cfd2cea7a98fc02570174b25622bf Mon Sep 17 00:00:00 2001 From: Nico Ritschel Date: Tue, 13 Oct 2026 15:05:10 -0700 Subject: [PATCH] Add service tokens for unattended MCP clients A bot or scheduled job cannot complete the browser OAuth flow, and MCP access tokens stop working when the Better Auth session behind them expires. ARTIFACTS_SERVICE_TOKENS maps service names to SHA-256 token digests; a matching bearer token on /mcp authenticates as that service account. Service tokens work on /mcp only and manage the team library only. --- cloudflare/auth-flow.test.ts | 43 +++++++++++++++++++ cloudflare/service-tokens.test.ts | 35 +++++++++++++++ cloudflare/service-tokens.ts | 46 ++++++++++++++++++++ cloudflare/worker.ts | 19 ++++++--- docs/authentication.md | 71 ++++++++++++++++++++++++++++--- 5 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 cloudflare/service-tokens.test.ts create mode 100644 cloudflare/service-tokens.ts diff --git a/cloudflare/auth-flow.test.ts b/cloudflare/auth-flow.test.ts index edd7373..8354877 100644 --- a/cloudflare/auth-flow.test.ts +++ b/cloudflare/auth-flow.test.ts @@ -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"; @@ -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"); @@ -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") { @@ -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); diff --git a/cloudflare/service-tokens.test.ts b/cloudflare/service-tokens.test.ts new file mode 100644 index 0000000..71a349a --- /dev/null +++ b/cloudflare/service-tokens.test.ts @@ -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); + } +}); diff --git a/cloudflare/service-tokens.ts b/cloudflare/service-tokens.ts new file mode 100644 index 0000000..bde8611 --- /dev/null +++ b/cloudflare/service-tokens.ts @@ -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: `{"": ""}`. + * A malformed table is a configuration error, never an empty table. */ +export function serviceTokenDigests(env: ServiceTokenEnvironment): Map { + const digests = new Map(); + 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(); + 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; +} diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 8868d04..8f4c608 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -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"; @@ -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; LIBRARIES: DurableObjectNamespace; BACKENDS: DurableObjectNamespace; @@ -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); diff --git a/docs/authentication.md b/docs/authentication.md index 7d12299..5f0644d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,6 +28,7 @@ credentials. The default local host needs no sign-in. | What you want to do | Start here | | --- | --- | | Sign in or connect your coding agent | [Connect to an existing deployment](#connect-to-an-existing-deployment) | +| Connect a bot or scheduled job without a browser | [Service tokens for unattended clients](#service-tokens-for-unattended-clients) | | Understand personal, team, and public access | [Library and URL permissions](#library-and-url-permissions) | | Set up sign-in for your deployment | [Choose an authentication mode](#choose-an-authentication-mode) | | Fix a failed sign-in or connection | [Troubleshooting](#troubleshooting) | @@ -54,6 +55,10 @@ you do not need to supply a client ID or secret for a supported public client. Check the connection by asking your agent to list artifacts and scripts without creating or changing anything. +**Unattended clients:** a chat bot or scheduled job cannot complete a browser +sign-in. An administrator can give it a +[service token](#service-tokens-for-unattended-clients) for the team library instead. + **Local host:** `artifacts host` serves `http://127.0.0.1:4786` and `http://127.0.0.1:4786/mcp` without sign-in in its default local configuration. For a shared or network deployment, an administrator must configure sign-in; @@ -357,6 +362,57 @@ deployed bucket storage, and workerd's local D1 is a separate database. Use the [workerd local setup](#local-better-auth-development) for routine auth development; the celld auth integration fixture bootstraps its own temporary schema. +## Service tokens for unattended clients + +A service token lets an unattended MCP client, such as a chat bot or a CI job, +manage the team library without a person signing in. Service tokens are +available in Better Auth mode. A service token: + +- **Authenticates `/mcp` only.** The gallery, management routes such as + `/api/tools`, and private app or script URLs still require a signed-in person. +- **Manages the team library only.** The MCP URL must include `library=team`; + any other request returns 403. A service account has no personal library, so + moving an item to its private library keeps the item in the team library. +- **Has an admitted team member's management rights there.** It can create, edit, + run and schedule apps and scripts, manage their secrets, and make links public. + Protect it accordingly. +- **Calls deployment plugins as `{ subject: "", authority: "service-token" }`.** + Plugins that authorize by user must account for that authority. +- **Does not expire.** Revoke it by removing its entry and redeploying. + +Generate a random token and its SHA-256 digest: + +```sh +token="$(openssl rand -base64 32)" +printf %s "$token" | shasum -a 256 | cut -d' ' -f1 +``` + +Configure only the digest. `ARTIFACTS_SERVICE_TOKENS` is a JSON object mapping +each service name (lowercase letters, digits and hyphens) to the lowercase hex +SHA-256 digest of its token: + +```json +{ + "ARTIFACTS_SERVICE_TOKENS": "{\"slack-bot\":\"\"}" +} +``` + +Deployment variables are stored with the deployment record, which is why the +variable holds digests rather than tokens. Give the token itself to the client +through the client's own secret store. Use a long random token: a fast digest +protects only a token that cannot be guessed. A malformed table makes `/mcp` +return 503 rather than being ignored. + +The client sends the token as a bearer credential to the team-library MCP URL: + +```text +https://artifacts.example.com/mcp?library=team&workspace=default +Authorization: Bearer +``` + +To rotate a token, add a second entry under a new name, move the client to the +new token, then remove the old entry. + ## Troubleshooting | Symptom | Check or next action | @@ -370,6 +426,9 @@ the celld auth integration fixture bootstraps its own temporary schema. | Gallery works but MCP returns 401 | Complete the MCP client's OAuth flow. A browser session cookie is not an MCP bearer token. Reconnect if its attached session expired or was signed out. | | MCP returns 403 `insufficient_scope` | Request `artifacts` scope and the canonical `/mcp` resource, then authorize again. | | MCP token does not work on `/api/tools` or a private URL | These routes use browser sessions in Better Auth mode. Use `/mcp` for OAuth programmatic management. | +| A service token returns 403 | Add `library=team` to the MCP URL. Service tokens cannot use a personal library. | +| A service token returns 401 | Send `Authorization: Bearer ` to `/mcp`, and check the configured digest is the SHA-256 of that exact token with no trailing newline. | +| `/mcp` returns 503 naming `ARTIFACTS_SERVICE_TOKENS` | Fix the table: a JSON object of lowercase service names to unique, lowercase hex SHA-256 digests. | | A public URL still prompts for Access sign-in | Add an Access bypass for that public path. The Artifacts link setting cannot override the edge. | | Management requests return 403 `Origin is not allowed` | Use the deployment's own origin. The management API rejects cross-origin browser requests. | | My personal library looks empty after changing auth mode | Private identities differ between Access and Better Auth; see the migration note below. | @@ -402,14 +461,16 @@ your MCP client and complete the browser sign-in described above. | Surface | Better Auth mode | Cloudflare Access mode | | --- | --- | --- | | Gallery and management routes such as `/api/tools` | Browser session cookie from provider sign-in | Valid signed Access assertion forwarded by Cloudflare | -| `/mcp` | OAuth access token issued by this deployment for its MCP resource | Managed OAuth token validated by Access, which forwards a signed assertion | +| `/mcp` | OAuth access token issued by this deployment for its MCP resource, or a [service token](#service-tokens-for-unattended-clients) for the team library | Managed OAuth token validated by Access, which forwards a signed assertion | | Private app or script URL | Browser session, with the current owner's library permissions | Valid Access assertion, with the current owner's library permissions | | Public app or script URL | No Artifacts sign-in; the handler can require application credentials | No Artifacts sign-in; the Access edge policy must also permit the path | -Artifacts does not currently issue personal API keys or support a client-credentials -grant for unattended management. In Better Auth mode, sending an MCP access token -to `/api/tools` does not authenticate that request: OAuth bearer authentication is -implemented only on `/mcp`. Use the MCP OAuth flow for programmatic management. +Artifacts does not issue personal API keys or support a client-credentials grant. +Unattended clients use administrator-configured +[service tokens](#service-tokens-for-unattended-clients) for the team library. +In Better Auth mode, sending an MCP access token or service token to `/api/tools` +does not authenticate that request: bearer authentication is implemented only on +`/mcp`. Use the MCP OAuth flow for programmatic management. Provider access tokens and `BETTER_AUTH_SECRET` are not Artifacts API credentials. In Better Auth mode, a custom MCP client should request the `artifacts` scope and