@@ -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