@@ -3,6 +3,7 @@ import { StrategyOptions } from '@octokit/auth-app/dist-types/types';
33import { request } from '@octokit/request' ;
44import { RequestInterface , RequestParameters } from '@octokit/types' ;
55import { getParameter } from '@aws-github-runner/aws-ssm-util' ;
6+ import { generateKeyPairSync } from 'node:crypto' ;
67import * as nock from 'nock' ;
78
89import { createGithubAppAuth , createOctokitClient } from './auth' ;
@@ -77,23 +78,12 @@ describe('Test createGithubAppAuth', () => {
7778 process . env . ENVIRONMENT = ENVIRONMENT ;
7879 } ) ;
7980
80- it ( 'Creates auth object with line breaks in SSH key. ' , async ( ) => {
81+ it ( 'Creates auth object with createJwt callback including jti claim ' , async ( ) => {
8182 // Arrange
82- const authOptions = {
83- appId : parseInt ( GITHUB_APP_ID ) ,
84- privateKey : `${ decryptedValue }
85- ${ decryptedValue } `,
86- installationId,
87- } ;
88-
89- const b64PrivateKeyWithLineBreaks = Buffer . from ( decryptedValue + '\n' + decryptedValue , 'binary' ) . toString (
90- 'base64' ,
91- ) ;
92- mockedGet . mockResolvedValueOnce ( GITHUB_APP_ID ) . mockResolvedValueOnce ( b64PrivateKeyWithLineBreaks ) ;
83+ mockedGet . mockResolvedValueOnce ( GITHUB_APP_ID ) . mockResolvedValueOnce ( b64 ) ;
9384
9485 const mockedAuth = vi . fn ( ) ;
9586 mockedAuth . mockResolvedValue ( { token } ) ;
96- // Add the required hook method to make it compatible with AuthInterface
9787 const mockWithHook = Object . assign ( mockedAuth , { hook : vi . fn ( ) } ) ;
9888 mockedCreatAppAuth . mockReturnValue ( mockWithHook ) ;
9989
@@ -102,21 +92,57 @@ ${decryptedValue}`,
10292
10393 // Assert
10494 expect ( mockedCreatAppAuth ) . toBeCalledTimes ( 1 ) ;
105- expect ( mockedCreatAppAuth ) . toBeCalledWith ( { ...authOptions } ) ;
95+ const callArgs = mockedCreatAppAuth . mock . calls [ 0 ] [ 0 ] as Record < string , unknown > ;
96+ expect ( callArgs . appId ) . toBe ( parseInt ( GITHUB_APP_ID ) ) ;
97+ expect ( callArgs . createJwt ) . toBeTypeOf ( 'function' ) ;
98+ expect ( callArgs ) . not . toHaveProperty ( 'privateKey' ) ;
99+ expect ( callArgs . installationId ) . toBe ( installationId ) ;
100+ } ) ;
101+
102+ it ( 'createJwt callback produces unique JWTs with jti' , async ( ) => {
103+ // Arrange — need a real RSA key since createJwt actually signs
104+ const { privateKey } = generateKeyPairSync ( 'rsa' , {
105+ modulusLength : 2048 ,
106+ privateKeyEncoding : { type : 'pkcs8' , format : 'pem' } ,
107+ publicKeyEncoding : { type : 'spki' , format : 'pem' } ,
108+ } ) ;
109+ const b64Key = Buffer . from ( privateKey as string ) . toString ( 'base64' ) ;
110+
111+ mockedGet . mockResolvedValueOnce ( GITHUB_APP_ID ) . mockResolvedValueOnce ( b64Key ) ;
112+
113+ let capturedCreateJwt : ( appId : string | number , timeDifference ?: number ) => Promise < { jwt : string } > ;
114+ mockedCreatAppAuth . mockImplementation ( ( opts : StrategyOptions ) => {
115+ capturedCreateJwt = ( opts as Record < string , unknown > ) . createJwt as typeof capturedCreateJwt ;
116+ const mockedAuth = vi . fn ( ) . mockResolvedValue ( { token } ) ;
117+ return Object . assign ( mockedAuth , { hook : vi . fn ( ) } ) ;
118+ } ) ;
119+
120+ // Act
121+ await createGithubAppAuth ( installationId ) ;
122+
123+ // Generate two JWTs and verify they are different (jti makes them unique)
124+ const jwt1 = await capturedCreateJwt ! ( 1 ) ;
125+ const jwt2 = await capturedCreateJwt ! ( 1 ) ;
126+
127+ // Assert — JWTs must differ even when generated in the same second
128+ expect ( jwt1 . jwt ) . not . toBe ( jwt2 . jwt ) ;
129+
130+ // Verify JWT structure: header.payload.signature
131+ const parts = jwt1 . jwt . split ( '.' ) ;
132+ expect ( parts ) . toHaveLength ( 3 ) ;
133+ const payload = JSON . parse ( Buffer . from ( parts [ 1 ] , 'base64url' ) . toString ( ) ) ;
134+ expect ( payload ) . toHaveProperty ( 'jti' ) ;
135+ expect ( payload ) . toHaveProperty ( 'iat' ) ;
136+ expect ( payload ) . toHaveProperty ( 'exp' ) ;
137+ expect ( payload ) . toHaveProperty ( 'iss' ) ;
106138 } ) ;
107139
108140 it ( 'Creates auth object for public GitHub' , async ( ) => {
109141 // Arrange
110- const authOptions = {
111- appId : parseInt ( GITHUB_APP_ID ) ,
112- privateKey : decryptedValue ,
113- installationId,
114- } ;
115142 mockedGet . mockResolvedValueOnce ( GITHUB_APP_ID ) . mockResolvedValueOnce ( b64 ) ;
116143
117144 const mockedAuth = vi . fn ( ) ;
118145 mockedAuth . mockResolvedValue ( { token } ) ;
119- // Add the required hook method to make it compatible with AuthInterface
120146 const mockWithHook = Object . assign ( mockedAuth , { hook : vi . fn ( ) } ) ;
121147 mockedCreatAppAuth . mockReturnValue ( mockWithHook ) ;
122148
@@ -128,7 +154,10 @@ ${decryptedValue}`,
128154 expect ( getParameter ) . toBeCalledWith ( PARAMETER_GITHUB_APP_KEY_BASE64_NAME ) ;
129155
130156 expect ( mockedCreatAppAuth ) . toBeCalledTimes ( 1 ) ;
131- expect ( mockedCreatAppAuth ) . toBeCalledWith ( { ...authOptions } ) ;
157+ const callArgs = mockedCreatAppAuth . mock . calls [ 0 ] [ 0 ] as Record < string , unknown > ;
158+ expect ( callArgs . appId ) . toBe ( parseInt ( GITHUB_APP_ID ) ) ;
159+ expect ( callArgs . createJwt ) . toBeTypeOf ( 'function' ) ;
160+ expect ( callArgs . installationId ) . toBe ( installationId ) ;
132161 expect ( mockedAuth ) . toBeCalledWith ( { type : authType } ) ;
133162 expect ( result . token ) . toBe ( token ) ;
134163 } ) ;
@@ -142,13 +171,6 @@ ${decryptedValue}`,
142171 ( ) => mockedRequestInterface as RequestInterface < object & RequestParameters > ,
143172 ) ;
144173
145- const authOptions = {
146- appId : parseInt ( GITHUB_APP_ID ) ,
147- privateKey : decryptedValue ,
148- installationId,
149- request : mockedRequestInterface . mockImplementation ( ( ) => ( { baseUrl : githubServerUrl } ) ) ,
150- } ;
151-
152174 mockedGet . mockResolvedValueOnce ( GITHUB_APP_ID ) . mockResolvedValueOnce ( b64 ) ;
153175 const mockedAuth = vi . fn ( ) ;
154176 mockedAuth . mockResolvedValue ( { token } ) ;
@@ -165,7 +187,11 @@ ${decryptedValue}`,
165187 expect ( getParameter ) . toBeCalledWith ( PARAMETER_GITHUB_APP_KEY_BASE64_NAME ) ;
166188
167189 expect ( mockedCreatAppAuth ) . toBeCalledTimes ( 1 ) ;
168- expect ( mockedCreatAppAuth ) . toBeCalledWith ( authOptions ) ;
190+ const callArgs = mockedCreatAppAuth . mock . calls [ 0 ] [ 0 ] as Record < string , unknown > ;
191+ expect ( callArgs . appId ) . toBe ( parseInt ( GITHUB_APP_ID ) ) ;
192+ expect ( callArgs . createJwt ) . toBeTypeOf ( 'function' ) ;
193+ expect ( callArgs . installationId ) . toBe ( installationId ) ;
194+ expect ( callArgs . request ) . toBeDefined ( ) ;
169195 expect ( mockedAuth ) . toBeCalledWith ( { type : authType } ) ;
170196 expect ( result . token ) . toBe ( token ) ;
171197 } ) ;
@@ -181,16 +207,9 @@ ${decryptedValue}`,
181207
182208 const installationId = undefined ;
183209
184- const authOptions = {
185- appId : parseInt ( GITHUB_APP_ID ) ,
186- privateKey : decryptedValue ,
187- request : mockedRequestInterface . mockImplementation ( ( ) => ( { baseUrl : githubServerUrl } ) ) ,
188- } ;
189-
190210 mockedGet . mockResolvedValueOnce ( GITHUB_APP_ID ) . mockResolvedValueOnce ( b64 ) ;
191211 const mockedAuth = vi . fn ( ) ;
192212 mockedAuth . mockResolvedValue ( { token } ) ;
193- // Add the required hook method to make it compatible with AuthInterface
194213 const mockWithHook = Object . assign ( mockedAuth , { hook : vi . fn ( ) } ) ;
195214 mockedCreatAppAuth . mockReturnValue ( mockWithHook ) ;
196215
@@ -202,7 +221,11 @@ ${decryptedValue}`,
202221 expect ( getParameter ) . toBeCalledWith ( PARAMETER_GITHUB_APP_KEY_BASE64_NAME ) ;
203222
204223 expect ( mockedCreatAppAuth ) . toBeCalledTimes ( 1 ) ;
205- expect ( mockedCreatAppAuth ) . toBeCalledWith ( authOptions ) ;
224+ const callArgs = mockedCreatAppAuth . mock . calls [ 0 ] [ 0 ] as Record < string , unknown > ;
225+ expect ( callArgs . appId ) . toBe ( parseInt ( GITHUB_APP_ID ) ) ;
226+ expect ( callArgs . createJwt ) . toBeTypeOf ( 'function' ) ;
227+ expect ( callArgs ) . not . toHaveProperty ( 'installationId' ) ;
228+ expect ( callArgs . request ) . toBeDefined ( ) ;
206229 expect ( mockedAuth ) . toBeCalledWith ( { type : authType } ) ;
207230 expect ( result . token ) . toBe ( token ) ;
208231 } ) ;
0 commit comments