diff --git a/config.default.json b/config.default.json index e7d964fc5..3ad7fee59 100644 --- a/config.default.json +++ b/config.default.json @@ -231,6 +231,10 @@ }, "required": { "GITHUB": "ghe.yourdomain.com", + "GITHUB_APP_ID": "APP_ID", + "GITHUB_APP_PRIVATE_PEM_PATH": "abc", + "GITHUB_APP_CLIENT_ID": "123", + "GITHUB_APP_INSTALLATION_ID": "123", "BROKER_CLIENT_URL": "https://:" } }, diff --git a/defaultFilters/github-server-app.json b/defaultFilters/github-server-app.json index 0b3715a8c..2f29a7f81 100644 --- a/defaultFilters/github-server-app.json +++ b/defaultFilters/github-server-app.json @@ -299,6 +299,16 @@ } ], "private": [ + { + "//": "look up repositories installation can access", + "method": "GET", + "path": "/installation/repositories", + "origin": "https://${GITHUB_API}", + "auth": { + "scheme": "bearer", + "token": "${ACCESS_TOKEN}" + } + }, { "//": "search for user's repos", "method": "GET", @@ -1287,7 +1297,11 @@ "//": "get details of the repo", "method": "GET", "path": "/repos/:name/:repo", - "origin": "https://${GITHUB_API}" + "origin": "https://${GITHUB_API}", + "auth": { + "scheme": "bearer", + "token": "${ACCESS_TOKEN}" + } }, { "//": "get the details of the commit to determine its SHA", diff --git a/lib/client/brokerClientPlugins/pluginManager.ts b/lib/client/brokerClientPlugins/pluginManager.ts index f75574c30..fd0ab932f 100644 --- a/lib/client/brokerClientPlugins/pluginManager.ts +++ b/lib/client/brokerClientPlugins/pluginManager.ts @@ -61,7 +61,10 @@ export const runStartupPlugins = async (clientOpts) => { string, BrokerPlugin[] >; - const connectionsKeys = Object.keys(clientOpts.config.connections); + + const connectionsKeys = clientOpts.config.connections + ? Object.keys(clientOpts.config.connections) + : []; for (const connectionKey of connectionsKeys) { if ( diff --git a/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts b/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts index f599f9c69..649300abb 100644 --- a/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts +++ b/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts @@ -1,15 +1,20 @@ // import { PostFilterPreparedRequest } from '../../../common/relay/prepareRequest'; +import { readFileSync } from 'node:fs'; import BrokerPlugin from '../abstractBrokerPlugin'; - +import { createPrivateKey } from 'node:crypto'; +import { sign } from 'jsonwebtoken'; +import { PostFilterPreparedRequest } from '../../../common/relay/prepareRequest'; +import { makeRequestToDownstream } from '../../../common/http/request'; export class Plugin extends BrokerPlugin { // Plugin Code and Name must be unique across all plugins. - pluginCode = 'GITHUB_SERVER_APP'; + pluginCode = 'GITHUB_SERVER_APP_PLUGIN'; pluginName = 'Github Server App Authentication Plugin'; description = ` Plugin to retrieve and manage credentials for Brokered Github Server App installs `; version = '0.1'; applicableBrokerTypes = ['github-server-app']; // Must match broker types + JWT_TTL = 10 * 60 * 1000; // Provide a way to include specific conditional logic to execute isPluginActive(): boolean { @@ -23,38 +28,205 @@ export class Plugin extends BrokerPlugin { // Function running upon broker client startup // Useful for credentials retrieval, initial setup, etc... async startUp(connectionConfig): Promise { - this.logger.info({ plugin: this.pluginName }, 'Running Startup'); - this.logger.info( - { config: connectionConfig }, - 'Connection Config passed to the plugin', - ); - // const data = { - // install_id: connectionConfig.GITHUB_APP_INSTALL_ID, - // client_id: connectionConfig.GITHUB_CLIENT_ID, - // client_secret: connectionConfig.GITHUB_CLIENT_SECRET, - // }; - // const formData = new URLSearchParams(data); - - // this.request = { - // url: `https://${connectionConfig.GITHUB_API}/oauth/path`, - // headers: { - // 'Content-Type': 'application/x-www-form-urlencoded', - // }, - // method: 'POST', - // body: formData.toString(), - // }; - // const response = await this.makeRequestToDownstream(this.request); - // if (response.statusCode && response.statusCode > 299) { - // throw Error('Error making request'); - // } + try { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.debug( + { plugin: this.pluginCode, config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + + // Generate the JWT + const now = Date.now(); + connectionConfig.JWT_TOKEN = this._getJWT( + Math.floor(now / 1000), // Current time in seconds + connectionConfig.GITHUB_APP_PRIVATE_PEM_PATH, + connectionConfig.GITHUB_APP_CLIENT_ID, + ); + this._setJWTLifecycleHandler(now, connectionConfig); + + connectionConfig.accessToken = await this._getAccessToken( + connectionConfig.GITHUB_API, + connectionConfig.GITHUB_APP_INSTALLATION_ID, + connectionConfig.JWT_TOKEN, + ); + connectionConfig.ACCESS_TOKEN = JSON.parse( + connectionConfig.accessToken, + ).token; + + this._setAccessTokenLifecycleHandler(connectionConfig); + } catch (err) { + this.logger.err( + { err }, + `Error in ${this.pluginName}-${this.pluginCode} startup.`, + ); + throw Error( + `Error in ${this.pluginName}-${this.pluginCode} startup. ${err}`, + ); + } + } + + _getJWT( + nowInSeconds: number, + privatePemPath: string, + githubAppClientId: string, + ): string { + // Read the contents of the PEM file + const privatePem = readFileSync(privatePemPath, 'utf8'); + const privateKey = createPrivateKey(privatePem); + + const payload = { + iat: nowInSeconds - 60, // Issued at time (60 seconds in the past) + exp: nowInSeconds + this.JWT_TTL / 1000, // Expiration time (10 minutes from now) + iss: githubAppClientId, // GitHub App's client ID + }; + + // Generate the JWT + return sign(payload, privateKey, { algorithm: 'RS256' }); + } + + _setJWTLifecycleHandler(now: number, connectionConfig) { + try { + if (connectionConfig.JWT_TOKEN) { + let timeoutHandlerId; + let timeoutHandler = async () => {}; + timeoutHandler = async () => { + try { + this.logger.debug( + { plugin: this.pluginCode }, + 'Refreshing github app JWT token', + ); + clearTimeout(timeoutHandlerId); + const timeoutHandlerNow = Date.now(); + connectionConfig.JWT_TOKEN = await this._getJWT( + Math.floor(timeoutHandlerNow / 1000), + connectionConfig.GITHUB_APP_PRIVATE_PEM_PATH, + connectionConfig.GITHUB_APP_CLIENT_ID, + ); + timeoutHandlerId = setTimeout( + timeoutHandler, + this._getTimeDifferenceInMsToFutureDate( + timeoutHandlerNow + this.JWT_TTL, + ) - 10000, + ); + connectionConfig.jwtTimeoutHandlerId = timeoutHandlerId; + } catch (err) { + this.logger.error( + { plugin: this.pluginCode, err }, + `Error refreshing JWT`, + ); + throw new Error(`${err}`); + } + }; + + timeoutHandlerId = setTimeout( + timeoutHandler, + this._getTimeDifferenceInMsToFutureDate(now + this.JWT_TTL) - 10000, + ); + connectionConfig.jwtTimeoutHandlerId = timeoutHandlerId; + } + } catch (err) { + this.logger.error( + { plugin: this.pluginCode, err }, + `Error setting JWT lifecycle handler.`, + ); + throw new Error(`${err}`); + } + } + + async _getAccessToken( + endpointHostname: string, + githubAppInstallationId: string, + jwtToken: string, + ) { + try { + const request: PostFilterPreparedRequest = { + url: `https://${endpointHostname}/app/installations/${githubAppInstallationId}/access_tokens`, + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${jwtToken}`, + 'user-agent': 'Snyk Broker Github App Plugin', + }, + method: 'POST', + }; + + const oauthResponse = await makeRequestToDownstream(request); + const accessToken = oauthResponse.body ?? ''; + return accessToken; + } catch (err) { + this.logger.error( + { plugin: this.pluginCode, err }, + `Error getting access token`, + ); + throw err; + } + } + + _setAccessTokenLifecycleHandler(connectionConfig) { + if (connectionConfig.accessToken) { + let timeoutHandlerId; + let timeoutHandler = async () => {}; + timeoutHandler = async () => { + try { + this.logger.debug( + { plugin: this.pluginCode }, + 'Refreshing github app access token', + ); + clearTimeout(timeoutHandlerId); + connectionConfig.accessToken = await this._getAccessToken( + connectionConfig.GITHUB_API, + connectionConfig.GITHUB_APP_INSTALLATION_ID, + connectionConfig.JWT_TOKEN, + ); + connectionConfig.ACCESS_TOKEN = JSON.parse( + connectionConfig.accessToken, + ).token; + this.logger.debug( + { plugin: this.pluginCode }, + `Refreshed access token expires at ${ + JSON.parse(connectionConfig.accessToken).expires_at + }`, + ); + timeoutHandlerId = setTimeout( + timeoutHandler, + this._getTimeDifferenceInMsToFutureDate( + JSON.parse(connectionConfig.accessToken).expires_at, + ) - 10000, + ); + connectionConfig.accessTokenTimeoutHandlerId = timeoutHandlerId; + } catch (err) { + this.logger.error( + { plugin: this.pluginCode, err }, + `Error setting Access Token lifecycle handler.`, + ); + throw new Error(`${err}`); + } + }; + timeoutHandlerId = setTimeout( + timeoutHandler, + this._getTimeDifferenceInMsToFutureDate( + JSON.parse(connectionConfig.accessToken).expires_at, + ) - 10000, + ); + connectionConfig.accessTokenTimeoutHandlerId = timeoutHandlerId; + } + } + _getTimeDifferenceInMsToFutureDate(targetDate) { + const currentDate = new Date(); + const futureDate = new Date(targetDate); + const timeDifference = futureDate.getTime() - currentDate.getTime(); + return timeDifference; } // Hook to run pre requests operations - Optional. Uncomment to enable // async preRequest( // connectionConfiguration: Record, - // postFilterPreparedRequest:PostFilterPreparedRequest, + // postFilterPreparedRequest: PostFilterPreparedRequest, // ) { - // this.logger.debug({ plugin: this.pluginName, connection: connectionConfiguration }, 'Running prerequest plugin'); + // this.logger.debug( + // { plugin: this.pluginName, connection: connectionConfiguration }, + // 'Running prerequest plugin', + // ); // return postFilterPreparedRequest; // } } diff --git a/package-lock.json b/package-lock.json index 7ce91a77f..fd7013850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "express-prom-bundle": "^5.1.5", "global-agent": "^3.0.0", "js-yaml": "^3.13.1", + "jsonwebtoken": "^9.0.2", "lodash.escaperegexp": "^4.1.2", "lodash.mapvalues": "^4.6.0", "lodash.merge": "^4.6.2", @@ -49,6 +50,7 @@ "@types/bunyan": "^1.8.8", "@types/global-agent": "^2.1.1", "@types/jest": "^28.1.3", + "@types/jsonwebtoken": "^9.0.6", "@types/minimist": "1.2.5", "@types/node": "^18.15.11", "@types/prettier": "2.6.0", @@ -2519,6 +2521,15 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/keygrip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", @@ -3393,6 +3404,11 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4065,6 +4081,14 @@ "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", "dev": true }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7432,6 +7456,46 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -7638,6 +7702,36 @@ "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.mapvalues": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", @@ -7654,6 +7748,11 @@ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", diff --git a/package.json b/package.json index 9993487bc..14a31a8b8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/bunyan": "^1.8.8", "@types/global-agent": "^2.1.1", "@types/jest": "^28.1.3", + "@types/jsonwebtoken": "^9.0.6", "@types/minimist": "1.2.5", "@types/node": "^18.15.11", "@types/prettier": "2.6.0", @@ -72,6 +73,7 @@ "express-prom-bundle": "^5.1.5", "global-agent": "^3.0.0", "js-yaml": "^3.13.1", + "jsonwebtoken": "^9.0.2", "lodash.escaperegexp": "^4.1.2", "lodash.mapvalues": "^4.6.0", "lodash.merge": "^4.6.2", diff --git a/test/fixtures/plugins/github-server-app/dummy.pem b/test/fixtures/plugins/github-server-app/dummy.pem new file mode 100644 index 000000000..c4580a5f5 --- /dev/null +++ b/test/fixtures/plugins/github-server-app/dummy.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCWoHbQPb6hChP+ +WrxuWlgpZSbrJ7goCle2QIt41ohjMDl3SMrqS7esrwffERVCu58VNCI+wHJs1YwM +A3Pyfi6ws2UIXtImO6HaMmPuWElWf2ki1O4u6rWrABYamdY2pyqIIt9thfWSRlFx +/EnqGjSSsGSe3nQtCvqx9f7d9I8LOTmCqoPtOCw/e66vMwe/NHWW7B0JHk7zHoJZ +Jn2lcZknS+DnhWm3Y05nw2phtbFIZ/gba28nYFc5L72IOZpFcPgT3IbVR72Lc0rc +4SJi1wrg9Cx7yFNFIoQs0mBkpFklFxHk4E/XlVJygf4nksTZMwoe6SztRgE6PNnJ +IZg+WftnAgMBAAECggEAD4XM3ham3mNdU8aXtEr6L+s9MK8LXZD8oLW+dH8DV4Qg +6p06pXI/z6YgCl8b0PXokIpVXHXVCkiS4gAPGGE5lZB/QSFocx6NG6E6xtVxWrPP +ABMafVpHINllmGsxy2MR0S1q12zJNcBVD16fuBDoSb+vjDxs6OFrRstiRGRkw2lY +ooQX113+Yc6/R1nRFvW1V+LIkY+hMqZvsBkOQo/cG5LS+qvozVln8L4MtW+pmcQw +esLfy0n8pk3xMA06162KDtLLwSzb/gF/1ugdN5+j56Wp/JECZsINkvSbNs/sfwy5 +llH2NqM06VYRJeoNHAb889C3KpIQ377HaYD/k81EPQKBgQDLKE8KJxwEMa9FmKeV +cPmgP1F1pP/DKLKyTbGMescYcs+8zy/6KNr7sJELJ+ENRBDeVwqLwbKJQI73CWAO +oBF0KllPVLaX0J4Nnu5LvxqopvPHmQfJUlrBwhaivkS1JF4B3qAOlA5BG1Ogth8g +hRPxqW6iIUyUsKv1NsogdyBx/QKBgQC9zkkdzON0ZQL4egio3WPHD7HVig3aHAsO +IYBp3Mlx/O3bx6otqa1DOd3CMeAmTAz5wJzOhK/MAHOpwgT+iAoiDFbxBsHCfrEP +HA0Z+vpun4Am8PQqdp6vCogO3Pg7aBlItw3g84VQ4robQ0bQD5ykWkAp7XQ8gC3J +LMyW4ug+MwKBgGJ/vaq/gY7rA/7rX71OFEnEyVsPz82wist2bfIdiTBqYhw6HBne ++yVy2zAceroy2Tbj3sIZ/NUdDvPpgMA2jZ/T9I9JFGqRBEC4YPMqyeMhZyrMIIFU +w5oT32Oyep+U7VtctB+9WxfoBujxxC/BNgVCT9id6oJhEk6G7QNGnt2FAoGBAJAJ +qcbpo3rC5Rw3T7cGOx/nMyc/2v835NPWbKLpoB3WuZLd1LFOYGPx1+3094tYj0hA ++T5nxxjjBuM+j5exGS95ecjzPbshdbBnszGSGtY0SIZEuKY42ncvYM0Wt3Itr3JV +KD0b0IHvbRgfV++wyUiYDLVEs77t7tEKJEAk9eWtAoGBAK+hXn/6mC8KO0AIQIMY +oo7JMuPjS6etKvbZ95p4bWrNt1D+y7X7UVsOt7z6dL4zdc3ljkME5A6Ya4KmYo5d +hX1wPG73cLR2qwrDN90igCtCv+u0rZvPuWxeui9+dW0w92zW1uMdouzbxkQO54dG +TFOAgDIWZzE22NhJt3L+E4/E +-----END PRIVATE KEY----- diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts new file mode 100644 index 000000000..e74db73cc --- /dev/null +++ b/test/helpers/utils.ts @@ -0,0 +1,5 @@ +export const delay = (ms) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; diff --git a/test/unit/plugins/brokerPlugins/github-server-app.test.ts b/test/unit/plugins/brokerPlugins/github-server-app.test.ts new file mode 100644 index 000000000..aec44e76d --- /dev/null +++ b/test/unit/plugins/brokerPlugins/github-server-app.test.ts @@ -0,0 +1,138 @@ +import { Plugin } from '../../../../lib/client/brokerClientPlugins/plugins/githubServerAppAuth'; +import { findProjectRoot } from '../../../../lib/common/config/config'; +import nock from 'nock'; +import { delay } from '../../../helpers/utils'; +import { clearTimeout } from 'timers'; + +describe('Github Server App Plugin', () => { + const pluginsFixturesFolderPath = `${findProjectRoot( + __dirname, + )}/test/fixtures/plugins/github-server-app`; + + it('Instantiate plugin', () => { + const config = {}; + const plugin = new Plugin(config); + + expect(plugin.pluginName).toEqual( + 'Github Server App Authentication Plugin', + ); + expect(plugin.pluginCode).toEqual('GITHUB_SERVER_APP_PLUGIN'); + expect(plugin.applicableBrokerTypes).toEqual(['github-server-app']); + }); + + it('GetJWT method', () => { + const dummyPrivateKeyPath = `${pluginsFixturesFolderPath}/dummy.pem`; + const dummyAppClientId = '1324567'; + const config = {}; + const plugin = new Plugin(config); + + const nowInSeconds = Math.floor(1715765665878 / 1000); + const jwt = plugin._getJWT( + nowInSeconds, + dummyPrivateKeyPath, + dummyAppClientId, + ); + expect(jwt).toEqual( + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTU3NjU2MDUsImV4cCI6MTcxNTc2NjI2NSwiaXNzIjoiMTMyNDU2NyJ9.K3bXPczfBSrBIiFdyJ9-PsYJAG6y0t0cNulnasS2ejcW9J8uCf4xdk1kp4z42Wka7UpcBKrHjZKlnjCA8e7Ge-NCtgW9_f3jX4kfXqagI7bdxaEgckWKkg2DSNNtZuT3WuXFEWKxQ5tIDB4npzFqrzL4_r2hQOjt9W81gA2oPHdIakY6juXZSAOen-O3KbB3dOzllj0kR7LZ5IKz7O2bVQcCRWw8dPoJQIPzpCv0iwf6SS6pAjXYj_9Slkw8REjPSVGlJozLmW9qjNl67s669OMnwOSqNn9B_Unegb599ZjUrZ4u0udo6Gk6TBnDqnd5qthcM8C6Ym6WG98UrxB27w', + ); + }); + + it('GetAccessToken method', async () => { + const dummyJwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTU3NjU2MDUsImV4cCI6MTcxNTc2NjI2NSwiaXNzIjoiMTMyNDU2NyJ9.K3bXPczfBSrBIiFdyJ9-PsYJAG6y0t0cNulnasS2ejcW9J8uCf4xdk1kp4z42Wka7UpcBKrHjZKlnjCA8e7Ge-NCtgW9_f3jX4kfXqagI7bdxaEgckWKkg2DSNNtZuT3WuXFEWKxQ5tIDB4npzFqrzL4_r2hQOjt9W81gA2oPHdIakY6juXZSAOen-O3KbB3dOzllj0kR7LZ5IKz7O2bVQcCRWw8dPoJQIPzpCv0iwf6SS6pAjXYj_9Slkw8REjPSVGlJozLmW9qjNl67s669OMnwOSqNn9B_Unegb599ZjUrZ4u0udo6Gk6TBnDqnd5qthcM8C6Ym6WG98UrxB27w'; + const dummyAppInstallId = '1324567'; + const config = {}; + const dummyAccessToken = { + token: 'mytokenvalue', + expires_at: '2024-05-15T10:40:32Z', + permissions: { + contents: 'write', + }, + repository_selection: 'all', + }; + nock('https://dummyendpoint') + .persist() + .post(`/app/installations/${dummyAppInstallId}/access_tokens`) + .reply(() => { + return [200, dummyAccessToken]; + }); + + const plugin = new Plugin(config); + + const accessToken = await plugin._getAccessToken( + 'dummyendpoint', + dummyAppInstallId, + dummyJwt, + ); + expect(JSON.parse(accessToken)).toEqual(dummyAccessToken); + }); + + it('Test time difference util method', () => { + const plugin = new Plugin({}); + const nowPlus10s = Date.now() + 10000; + const timeDifference = + plugin._getTimeDifferenceInMsToFutureDate(nowPlus10s); + expect(timeDifference).toEqual(10000); + }); + + it('Test JWT lifecycle Handler', async () => { + const jwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTU3NjU2MDUsImV4cCI6MTcxNTc2NjI2NSwiaXNzIjoiMTMyNDU2NyJ9.K3bXPczfBSrBIiFdyJ9-PsYJAG6y0t0cNulnasS2ejcW9J8uCf4xdk1kp4z42Wka7UpcBKrHjZKlnjCA8e7Ge-NCtgW9_f3jX4kfXqagI7bdxaEgckWKkg2DSNNtZuT3WuXFEWKxQ5tIDB4npzFqrzL4_r2hQOjt9W81gA2oPHdIakY6juXZSAOen-O3KbB3dOzllj0kR7LZ5IKz7O2bVQcCRWw8dPoJQIPzpCv0iwf6SS6pAjXYj_9Slkw8REjPSVGlJozLmW9qjNl67s669OMnwOSqNn9B_Unegb599ZjUrZ4u0udo6Gk6TBnDqnd5qthcM8C6Ym6WG98UrxB27w'; + const dummyPrivateKeyPath = `${pluginsFixturesFolderPath}/dummy.pem`; + const dummyAppClientId = '1324567'; + const config = { + GITHUB_APP_PRIVATE_PEM_PATH: dummyPrivateKeyPath, + GITHUB_APP_CLIENT_ID: dummyAppClientId, + JWT_TOKEN: `${jwt}`, + }; + const plugin = new Plugin(config); + plugin.JWT_TTL = 10; // overriding for testing + const now = Date.now(); + plugin._setJWTLifecycleHandler(now, config); + await delay(100); + expect(config.JWT_TOKEN).not.toEqual(jwt); + expect(config.JWT_TOKEN.length).toBeGreaterThan(400); + clearTimeout(config['jwtTimeoutHandlerId']); + }); + + it('Test access token lifecycle Handler', async () => { + const jwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTU3NjU2MDUsImV4cCI6MTcxNTc2NjI2NSwiaXNzIjoiMTMyNDU2NyJ9.K3bXPczfBSrBIiFdyJ9-PsYJAG6y0t0cNulnasS2ejcW9J8uCf4xdk1kp4z42Wka7UpcBKrHjZKlnjCA8e7Ge-NCtgW9_f3jX4kfXqagI7bdxaEgckWKkg2DSNNtZuT3WuXFEWKxQ5tIDB4npzFqrzL4_r2hQOjt9W81gA2oPHdIakY6juXZSAOen-O3KbB3dOzllj0kR7LZ5IKz7O2bVQcCRWw8dPoJQIPzpCv0iwf6SS6pAjXYj_9Slkw8REjPSVGlJozLmW9qjNl67s669OMnwOSqNn9B_Unegb599ZjUrZ4u0udo6Gk6TBnDqnd5qthcM8C6Ym6WG98UrxB27w'; + const dummyAppInstallId = '1324567'; + const dummyAccessToken = { + token: 'mytokenvalue', + expires_at: `${ + new Date(new Date().getTime() + 10000).toISOString().slice(0, -5) + 'Z' + }`, + permissions: { + contents: 'write', + }, + repository_selection: 'all', + }; + const renewedDummyAccessToken = { + token: 'mytokenvalue', + expires_at: '2024-05-15T10:40:32Z', + permissions: { + contents: 'write', + }, + repository_selection: 'all', + }; + nock('https://dummyendpoint') + .persist() + .post(`/app/installations/${dummyAppInstallId}/access_tokens`) + .reply(() => { + return [200, renewedDummyAccessToken]; + }); + const config = { + accessToken: JSON.stringify(dummyAccessToken), + GITHUB_API: 'dummyendpoint', + GITHUB_APP_INSTALLATION_ID: dummyAppInstallId, + JWT_TOKEN: `${jwt}`, + }; + const plugin = new Plugin(config); + plugin._setAccessTokenLifecycleHandler(config); + await delay(100); + expect(JSON.parse(config.accessToken)).toEqual(renewedDummyAccessToken); + clearTimeout(config['accessTokenTimeoutHandlerId']); + }); +});