Skip to content

Commit ed43eca

Browse files
committed
fix(labeler): read Access group claims from the verified custom object
Cloudflare Access places IdP group membership inside the verified application token's `custom` object (a `groups` claim configured on the SAML/OIDC integration), never as a top-level `groups` claim. The role mapper read `payload.groups`, which Access never populates, so the group-only reviewer/admin allowlist granted no roles to human operators whose access is conferred by group. Read the `groups` array from the verified `payload.custom` object, guarding the `custom` record and the array shape strictly. Restrict group extraction to human identities: a service token is authorized solely by common_name and carries no IdP identity, so a `custom.groups` on a service token must never confer a role (moving the group claim under `custom` would otherwise open a fail-open path). Email/common_name resolution is otherwise unchanged.
1 parent 7074e81 commit ed43eca

2 files changed

Lines changed: 105 additions & 24 deletions

File tree

apps/labeler/src/access-auth.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,26 @@ export async function verifyAccessRequest(
129129
}
130130

131131
const principal = identity.kind === "service" ? identity.commonName : identity.email;
132-
const principals = new Set<string>([principal, ...groupPrincipals(payload.groups)]);
132+
// Group membership authorizes humans only. A service token carries no IdP
133+
// identity — it is authorized solely by common_name — so a `custom.groups`
134+
// on a service token must never confer a role.
135+
const principals = new Set<string>([
136+
principal,
137+
...(identity.kind === "human" ? groupPrincipals(payload.custom) : []),
138+
]);
133139
const roles: OperatorRole[] = [];
134140
if (config.admins.some((admin) => principals.has(admin))) roles.push("admin");
135141
if (config.reviewers.some((reviewer) => principals.has(reviewer))) roles.push("reviewer");
136142

137143
return { ...identity, roles };
138144
}
139145

140-
function groupPrincipals(groups: unknown): readonly string[] {
146+
// Cloudflare Access surfaces IdP group membership inside the verified token's
147+
// `custom` object (as a `groups` claim configured on the SAML/OIDC integration),
148+
// never as a top-level claim. Read the group principals from there.
149+
function groupPrincipals(custom: unknown): readonly string[] {
150+
if (!isRecord(custom)) return [];
151+
const groups = custom.groups;
141152
if (!Array.isArray(groups)) return [];
142153
return groups.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
143154
}

apps/labeler/test/access-auth.test.ts

Lines changed: 92 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ interface MintOverrides {
2828
email?: string;
2929
common_name?: string;
3030
groups?: unknown;
31+
custom?: unknown;
3132
issuer?: string;
3233
audience?: string;
3334
expiresInSeconds?: number;
@@ -102,9 +103,9 @@ describe("verifyAccessRequest", () => {
102103
});
103104
});
104105

105-
it("maps roles from a groups claim", async () => {
106+
it("maps roles from a groups claim nested under the verified custom object", async () => {
106107
const token = await mintToken(
107-
{ email: "someone@example.com", groups: ["emdash-labeler-reviewers"] },
108+
{ email: "someone@example.com", custom: { groups: ["emdash-labeler-reviewers"] } },
108109
signKey,
109110
);
110111
const identity = await verifyAccessRequest(
@@ -115,6 +116,35 @@ describe("verifyAccessRequest", () => {
115116
expect(identity.roles).toEqual(["reviewer"]);
116117
});
117118

119+
it("ignores a top-level groups claim (Access places IdP groups under custom)", async () => {
120+
const token = await mintToken(
121+
{ email: "someone@example.com", groups: ["emdash-labeler-admins"] },
122+
signKey,
123+
);
124+
const identity = await verifyAccessRequest(
125+
requestWith({ "Cf-Access-Jwt-Assertion": token }),
126+
baseConfig(),
127+
resolver,
128+
);
129+
expect(identity.roles).toEqual([]);
130+
});
131+
132+
it("maps both admin and reviewer when custom.groups lists both", async () => {
133+
const token = await mintToken(
134+
{
135+
email: "someone@example.com",
136+
custom: { groups: ["emdash-labeler-admins", "emdash-labeler-reviewers"] },
137+
},
138+
signKey,
139+
);
140+
const identity = await verifyAccessRequest(
141+
requestWith({ "Cf-Access-Jwt-Assertion": token }),
142+
baseConfig(),
143+
resolver,
144+
);
145+
expect(identity.roles).toEqual(["admin", "reviewer"]);
146+
});
147+
118148
it("grants only reviewer role for a reviewer-only principal", async () => {
119149
const token = await mintToken({ email: "reviewer@example.com" }, signKey);
120150
const identity = await verifyAccessRequest(
@@ -330,33 +360,39 @@ describe("verifyAccessRequest", () => {
330360
).rejects.toMatchObject({ reason: "invalid-token" });
331361
});
332362

333-
it("ignores a groups claim of the wrong shape without crashing", async () => {
334-
const stringShape = await mintToken(
335-
{ email: "reviewer@example.com", groups: "emdash-labeler-reviewers" },
336-
signKey,
337-
);
338-
const numberArrayShape = await mintToken(
339-
{ email: "reviewer@example.com", groups: [1, 2, 3] },
340-
signKey,
341-
);
363+
it("ignores malformed custom / groups shapes without crashing", async () => {
364+
// custom itself is not an object; custom.groups is a string not an array;
365+
// custom.groups is a non-string array; custom is absent. None should throw
366+
// and none should leak a group principal — the reviewer role here comes
367+
// only from the email allowlist.
368+
const shapes: unknown[] = [
369+
"custom-is-a-string",
370+
{ groups: "emdash-labeler-reviewers" },
371+
{ groups: [1, 2, 3] },
372+
{ groups: { nested: true } },
373+
];
374+
for (const custom of shapes) {
375+
const token = await mintToken({ email: "reviewer@example.com", custom }, signKey);
376+
const identity = await verifyAccessRequest(
377+
requestWith({ "Cf-Access-Jwt-Assertion": token }),
378+
baseConfig(),
379+
resolver,
380+
);
381+
expect(identity.roles).toEqual(["reviewer"]);
382+
}
342383

343-
const identityA = await verifyAccessRequest(
344-
requestWith({ "Cf-Access-Jwt-Assertion": stringShape }),
345-
baseConfig(),
346-
resolver,
347-
);
348-
const identityB = await verifyAccessRequest(
349-
requestWith({ "Cf-Access-Jwt-Assertion": numberArrayShape }),
384+
const noCustom = await mintToken({ email: "reviewer@example.com" }, signKey);
385+
const identity = await verifyAccessRequest(
386+
requestWith({ "Cf-Access-Jwt-Assertion": noCustom }),
350387
baseConfig(),
351388
resolver,
352389
);
353-
expect(identityA.roles).toEqual(["reviewer"]);
354-
expect(identityB.roles).toEqual(["reviewer"]);
390+
expect(identity.roles).toEqual(["reviewer"]);
355391
});
356392

357-
it("does not treat an empty-string group entry as a principal", async () => {
393+
it("does not treat an empty-string custom group entry as a principal", async () => {
358394
const token = await mintToken(
359-
{ email: "nobody@example.com", groups: ["", "not-a-configured-group"] },
395+
{ email: "nobody@example.com", custom: { groups: ["", "not-a-configured-group"] } },
360396
signKey,
361397
);
362398
// An empty-string allowlist entry can only match if an empty group leaks in as a principal.
@@ -368,6 +404,40 @@ describe("verifyAccessRequest", () => {
368404
expect(identity.roles).toEqual([]);
369405
});
370406

407+
it("never lets a service token gain a role from custom.groups (service = common_name only)", async () => {
408+
// A service token carries no IdP identity, so a `custom.groups` that
409+
// happens to match an allowlist entry must NOT confer a role — otherwise
410+
// moving the group claim under `custom` opens a fail-open path.
411+
const unauthorized = await mintToken(
412+
{ common_name: "unknown-service", custom: { groups: ["emdash-labeler-admins"] } },
413+
signKey,
414+
);
415+
const identityA = await verifyAccessRequest(
416+
requestWith({ "Cf-Access-Jwt-Assertion": unauthorized }),
417+
baseConfig(),
418+
resolver,
419+
);
420+
expect(identityA.roles).toEqual([]);
421+
422+
// A service token authorized by common_name keeps exactly that role; a
423+
// reviewer group in its custom object adds nothing.
424+
const authorized = await mintToken(
425+
{ common_name: "ci-automation", custom: { groups: ["emdash-labeler-reviewers"] } },
426+
signKey,
427+
);
428+
const identityB = await verifyAccessRequest(
429+
requestWith({ "Cf-Access-Jwt-Assertion": authorized }),
430+
baseConfig({ admins: ["ci-automation"] }),
431+
resolver,
432+
);
433+
expect(identityB).toEqual<OperatorIdentity>({
434+
kind: "service",
435+
commonName: "ci-automation",
436+
sub: "user-sub-1",
437+
roles: ["admin"],
438+
});
439+
});
440+
371441
it("never includes the raw token in a thrown error message", async () => {
372442
const token = await mintToken({ email: "admin@example.com" }, otherKey);
373443
try {

0 commit comments

Comments
 (0)