Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/cloudflare-service-token-subjects.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 23 additions & 0 deletions apps/host-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<service-token-client-id>.access=<human-access-sub>
```

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
Expand Down
54 changes: 42 additions & 12 deletions apps/host-cloudflare/src/auth/cloudflare-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const config: CloudflareConfig = {
accessNameClaim: "name",
accessGroupsClaim: "groups",
adminEmails: ["[email protected]"],
accessServiceTokenSubjects: {},
organizationId: "default",
organizationName: "Default",
organizationSlug: "default",
Expand All @@ -24,30 +25,59 @@ describe("principalFromAccessClaims", () => {
{ sub: "user-123", email: "[email protected]", name: "Person", groups: ["eng"] },
config,
);
expect(p.accountId).toBe("user-123");
expect(p.email).toBe("[email protected]");
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("[email protected]");
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: "[email protected]" }, 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: ["[email protected]"],
accessServiceTokenSubjects: { "df8a20db.access": "user-123" },
};
const human = principalFromAccessClaims(
{ sub: "user-123", email: "[email protected]", name: "Person" },
mappedConfig,
);
const service = principalFromAccessClaims(
{
common_name: "df8a20db.access",
email: "[email protected]",
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: "[email protected]" }, config);
expect(p.roles).toEqual(["member"]);
expect(p?.roles).toEqual(["member"]);
});
});
32 changes: 27 additions & 5 deletions apps/host-cloudflare/src/auth/cloudflare-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,25 +28,47 @@ import type { CloudflareConfig } from "../config";
export const principalFromAccessClaims = (
claims: Record<string, unknown>,
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"],
};
};

Expand Down
41 changes: 41 additions & 0 deletions apps/host-cloudflare/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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");
});
});
34 changes: 34 additions & 0 deletions apps/host-cloudflare/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<service-token common_name>=<human Access sub>` 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;
Expand All @@ -64,6 +68,7 @@ export interface CloudflareConfig {
readonly accessNameClaim: string;
readonly accessGroupsClaim: string;
readonly adminEmails: readonly string[];
readonly accessServiceTokenSubjects: Readonly<Record<string, string>>;
readonly organizationId: string;
readonly organizationName: string;
/** URL slug for org-prefixed console paths (`/<slug>/policies`). */
Expand Down Expand Up @@ -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<Record<string, string>> => {
const seenCommonNames = new Set<string>();
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 "<common_name>=<Access sub>" entries',
);
}
seenCommonNames.add(commonName);
return [commonName, subject];
}),
);
};

const normalizeAccessTeamDomain = (value: string | undefined): string =>
(value ?? "")
.trim()
Expand Down Expand Up @@ -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),
Expand Down