From 3bea310911aa699ca5f2f322d11c006b108fc0f8 Mon Sep 17 00:00:00 2001 From: Tim Kleyersburg Date: Sun, 16 Aug 2026 16:42:47 +0200 Subject: [PATCH] feat(cloudflare): map service tokens to user subjects --- .../cloudflare-service-token-subjects.md | 10 ++++ apps/host-cloudflare/README.md | 23 ++++++++ .../src/auth/cloudflare-access.test.ts | 54 ++++++++++++++----- .../src/auth/cloudflare-access.ts | 32 +++++++++-- apps/host-cloudflare/src/config.test.ts | 41 ++++++++++++++ apps/host-cloudflare/src/config.ts | 34 ++++++++++++ 6 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 .changeset/cloudflare-service-token-subjects.md diff --git a/.changeset/cloudflare-service-token-subjects.md b/.changeset/cloudflare-service-token-subjects.md new file mode 100644 index 0000000000..2959c7cba2 --- /dev/null +++ b/.changeset/cloudflare-service-token-subjects.md @@ -0,0 +1,10 @@ +--- +"executor": patch +--- + +**Add: map Cloudflare Access service tokens to stable human subjects** + +Cloudflare-hosted Executor instances can explicitly map a dedicated service +token to a human Access `sub`, allowing the token to use that subject's personal +connections. Mapped tokens remain non-admin members and do not inherit email or +group claims. diff --git a/apps/host-cloudflare/README.md b/apps/host-cloudflare/README.md index 9700570289..6972ef5ddf 100644 --- a/apps/host-cloudflare/README.md +++ b/apps/host-cloudflare/README.md @@ -64,6 +64,29 @@ The Access values are live Worker variables, not values in `wrangler.jsonc`. Wrangler's `keep_vars` option preserves them during later code deploys. Run the command above again whenever you need to change them. +### Let a service token use one human's personal connections + +Cloudflare service-token JWTs have a `common_name` but no human `sub` or email, +so Executor treats them as separate accounts by default. To let one dedicated +MCP token use the same personal connections as a human login, map that token's +`common_name` to the human Access JWT's stable `sub`: + +```bash +bunx wrangler deploy \ + --var ACCESS_SERVICE_TOKEN_SUBJECTS:.access= +``` + +The mapping changes only the account that owns user-scoped data. A service +token always remains a `member`; it never inherits the human's admin role or +group claims. Treat the service-token secret as a bearer credential with access +to that human's personal connections. + +Read the human `sub` from Cloudflare's `/cdn-cgi/access/get-identity` endpoint +while signed in as that user. The service-token Client ID is the JWT's +`common_name`. Multiple mappings are comma-separated, and duplicate token IDs +are rejected at startup. When adding or changing one mapping, pass the complete +mapping list again because the Worker variable stores the whole value. + ## Local development ```bash diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts index 8d4a75151c..a8c230cd0a 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.test.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.test.ts @@ -9,6 +9,7 @@ const config: CloudflareConfig = { accessNameClaim: "name", accessGroupsClaim: "groups", adminEmails: ["admin@example.com"], + accessServiceTokenSubjects: {}, organizationId: "default", organizationName: "Default", organizationSlug: "default", @@ -24,30 +25,59 @@ describe("principalFromAccessClaims", () => { { sub: "user-123", email: "person@example.com", name: "Person", groups: ["eng"] }, config, ); - expect(p.accountId).toBe("user-123"); - expect(p.email).toBe("person@example.com"); - expect(p.name).toBe("Person"); - expect(p.roles).toEqual(["eng"]); - expect(p.organizationId).toBe("default"); + expect(p?.accountId).toBe("user-123"); + expect(p?.email).toBe("person@example.com"); + expect(p?.name).toBe("Person"); + expect(p?.roles).toEqual(["eng"]); + expect(p?.organizationId).toBe("default"); }); it("grants admin when the email is in the allowlist", () => { const p = principalFromAccessClaims({ sub: "u", email: "ADMIN@example.com" }, config); - expect(p.roles).toContain("admin"); + expect(p?.roles).toContain("admin"); }); it("gives a SERVICE TOKEN (common_name, no email/sub) a stable identity", () => { // Cloudflare Access service-token JWT: common_name set, email/sub absent. const p = principalFromAccessClaims({ common_name: "df8a20db.access", type: "app" }, config); - expect(p.accountId).toBe("df8a20db.access"); // not empty — stable per token - expect(p.name).toBe("df8a20db.access"); - expect(p.email).toBe(""); - expect(p.roles).toEqual(["member"]); // a token is a member, not an admin - expect(p.organizationId).toBe("default"); + expect(p?.accountId).toBe("df8a20db.access"); // not empty — stable per token + expect(p?.name).toBe("df8a20db.access"); + expect(p?.email).toBe(""); + expect(p?.roles).toEqual(["member"]); // a token is a member, not an admin + expect(p?.organizationId).toBe("default"); + }); + + it("maps a dedicated SERVICE TOKEN to the human's stable subject without copying privileges", () => { + const mappedConfig: CloudflareConfig = { + ...config, + adminEmails: ["person@example.com"], + accessServiceTokenSubjects: { "df8a20db.access": "user-123" }, + }; + const human = principalFromAccessClaims( + { sub: "user-123", email: "Person@Example.com", name: "Person" }, + mappedConfig, + ); + const service = principalFromAccessClaims( + { + common_name: "df8a20db.access", + email: "person@example.com", + groups: ["admin", "eng"], + type: "app", + }, + mappedConfig, + ); + + expect(service?.accountId).toBe(human?.accountId); + expect(service?.email).toBe(""); + expect(service?.roles).toEqual(["member"]); + }); + + it("rejects verified claims without a human or service-token identity", () => { + expect(principalFromAccessClaims({ type: "app" }, config)).toBeNull(); }); it("defaults to member when there are no groups and no admin match", () => { const p = principalFromAccessClaims({ sub: "u", email: "nobody@other.com" }, config); - expect(p.roles).toEqual(["member"]); + expect(p?.roles).toEqual(["member"]); }); }); diff --git a/apps/host-cloudflare/src/auth/cloudflare-access.ts b/apps/host-cloudflare/src/auth/cloudflare-access.ts index 7590245d40..7f39d4b8a0 100644 --- a/apps/host-cloudflare/src/auth/cloudflare-access.ts +++ b/apps/host-cloudflare/src/auth/cloudflare-access.ts @@ -28,25 +28,47 @@ import type { CloudflareConfig } from "../config"; export const principalFromAccessClaims = ( claims: Record, config: CloudflareConfig, -): Principal => { - const email = typeof claims.email === "string" ? claims.email : ""; +): Principal | null => { + const claimedEmail = typeof claims.email === "string" ? claims.email : ""; const sub = typeof claims.sub === "string" && claims.sub.length > 0 ? claims.sub : ""; const commonName = typeof claims.common_name === "string" ? claims.common_name : ""; + const isServiceToken = claims.type === "app" && sub.length === 0 && commonName.length > 0; + const email = isServiceToken ? "" : claimedEmail; const nameClaim = claims[config.accessNameClaim]; const groupsClaim = claims[config.accessGroupsClaim]; const groups = Array.isArray(groupsClaim) ? groupsClaim.map(String) : []; - const isAdmin = email.length > 0 && config.adminEmails.includes(email.toLowerCase()); + const mappedServiceSubject = isServiceToken + ? (config.accessServiceTokenSubjects[commonName.toLowerCase()] ?? "") + : ""; + const isAdmin = + !isServiceToken && email.length > 0 && config.adminEmails.includes(email.toLowerCase()); + const accountId = mappedServiceSubject || sub || email || commonName; + + // A valid signature alone is not an identity. Reject claims that provide no + // human subject, email fallback, or service-token common_name. + if (accountId.length === 0) return null; return { kind: "member", - accountId: sub || email || commonName, + // An explicit mapping lets a dedicated service token reach the same + // personal connections as a human Access subject. Human identities keep + // Access' stable `sub`; unmapped service tokens keep their own common_name. + accountId, organizationId: config.organizationId, organizationName: config.organizationName, organizationSlug: config.organizationSlug, email, name: typeof nameClaim === "string" ? nameClaim : commonName || null, avatarUrl: null, - roles: isAdmin ? ["admin", ...groups] : groups.length > 0 ? groups : ["member"], + // A bearer service token never inherits the mapped human's admin or group + // privileges. The shared accountId grants access only to user-owned data. + roles: isServiceToken + ? ["member"] + : isAdmin + ? ["admin", ...groups] + : groups.length > 0 + ? groups + : ["member"], }; }; diff --git a/apps/host-cloudflare/src/config.test.ts b/apps/host-cloudflare/src/config.test.ts index 39aab1e7d3..82b5cb0088 100644 --- a/apps/host-cloudflare/src/config.test.ts +++ b/apps/host-cloudflare/src/config.test.ts @@ -53,9 +53,49 @@ describe("loadConfig", () => { accessTeamDomain: "Team.cloudflareaccess.com", accessAud: "aud-tag", adminEmails: [], + accessServiceTokenSubjects: {}, enableDevAuth: false, }); }); + + it("normalises dedicated service-token subject mappings", () => { + expect( + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + ACCESS_SERVICE_TOKEN_SUBJECTS: "ABC123.access=user-123, DEF456.access=user-456", + }), + ).accessServiceTokenSubjects, + ).toEqual({ + "abc123.access": "user-123", + "def456.access": "user-456", + }); + }); + + it("rejects malformed service-token subject mappings", () => { + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + ACCESS_SERVICE_TOKEN_SUBJECTS: "missing-subject", + }), + ), + ).toThrowError("ACCESS_SERVICE_TOKEN_SUBJECTS"); + }); + + it("rejects duplicate service-token subject mappings", () => { + expect(() => + loadConfig( + makeEnv({ + ACCESS_TEAM_DOMAIN: "team.cloudflareaccess.com", + ACCESS_AUD: "aud-tag", + ACCESS_SERVICE_TOKEN_SUBJECTS: "ABC123.access=user-123, abc123.access=user-456", + }), + ), + ).toThrowError("ACCESS_SERVICE_TOKEN_SUBJECTS"); + }); }); describe("Cloudflare deployment configuration", () => { @@ -69,6 +109,7 @@ describe("Cloudflare deployment configuration", () => { expect(config.vars).not.toHaveProperty("ACCESS_TEAM_DOMAIN"); expect(config.vars).not.toHaveProperty("ACCESS_AUD"); expect(config.vars).not.toHaveProperty("ADMIN_EMAILS"); + expect(config.vars).not.toHaveProperty("ACCESS_SERVICE_TOKEN_SUBJECTS"); expect(config.vars).toHaveProperty("ENABLE_DEV_AUTH", "false"); }); }); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index c397c4ef87..d96953fc98 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -40,6 +40,10 @@ export interface CloudflareEnv { readonly ACCESS_GROUPS_CLAIM?: string; /** Comma-separated emails granted the admin role. */ readonly ADMIN_EMAILS?: string; + /** Comma-separated `=` mappings. + * A mapped service token can use the human subject's personal connections, + * but it remains a non-admin member. */ + readonly ACCESS_SERVICE_TOKEN_SUBJECTS?: string; /** The single organization id/name every authenticated user belongs to. */ readonly SELF_HOSTED_ORG_ID?: string; readonly SELF_HOSTED_ORG_NAME?: string; @@ -64,6 +68,7 @@ export interface CloudflareConfig { readonly accessNameClaim: string; readonly accessGroupsClaim: string; readonly adminEmails: readonly string[]; + readonly accessServiceTokenSubjects: Readonly>; readonly organizationId: string; readonly organizationName: string; /** URL slug for org-prefixed console paths (`//policies`). */ @@ -92,6 +97,34 @@ const splitLower = (value: string | undefined): readonly string[] => .map((part) => part.trim().toLowerCase()) .filter((part) => part.length > 0); +const parseServiceTokenSubjects = (value: string | undefined): Readonly> => { + const seenCommonNames = new Set(); + return Object.fromEntries( + (value ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + const separator = part.indexOf("="); + const commonName = part.slice(0, separator).trim().toLowerCase(); + const subject = part.slice(separator + 1).trim(); + if ( + separator <= 0 || + commonName.length === 0 || + subject.length === 0 || + seenCommonNames.has(commonName) + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: an invalid identity map must fail closed at boot + throw new Error( + 'ACCESS_SERVICE_TOKEN_SUBJECTS must contain unique comma-separated "=" entries', + ); + } + seenCommonNames.add(commonName); + return [commonName, subject]; + }), + ); +}; + const normalizeAccessTeamDomain = (value: string | undefined): string => (value ?? "") .trim() @@ -161,6 +194,7 @@ export const loadConfig = (env: CloudflareConfigEnv): CloudflareConfig => { accessNameClaim: env.ACCESS_NAME_CLAIM ?? "name", accessGroupsClaim: env.ACCESS_GROUPS_CLAIM ?? "groups", adminEmails: splitLower(env.ADMIN_EMAILS), + accessServiceTokenSubjects: parseServiceTokenSubjects(env.ACCESS_SERVICE_TOKEN_SUBJECTS), organizationId: env.SELF_HOSTED_ORG_ID ?? "default", organizationName: env.SELF_HOSTED_ORG_NAME ?? "Default", organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG),