From e0a165cb8ce344cb5470c7586f59a64b298af6d1 Mon Sep 17 00:00:00 2001 From: Antoine Arlaud Date: Fri, 3 May 2024 16:46:40 +0200 Subject: [PATCH 1/3] feat: add broker plugin system --- config.universal.json.sample | 15 + .../abstractBrokerPlugin.ts | 57 +++ .../brokerClientPlugins/pluginManager.ts | 126 ++++++ .../plugins/githubServerAppAuth.ts | 60 +++ lib/client/hooks/startup/processHooks.ts | 5 + lib/client/index.ts | 22 +- lib/common/relay/forwardWebsocketRequest.ts | 9 + lib/common/relay/prepareRequest.ts | 6 +- lib/common/types/options.ts | 1 + test/fixtures/plugins/dummy 3 plugin.js | 36 ++ .../fixtures/plugins/dummy 3 second plugin.js | 40 ++ test/fixtures/plugins/dummy.js | 35 ++ test/fixtures/plugins/dummy2.js | 27 ++ .../pluginsDuplicates/dummy duplicate.js | 35 ++ test/fixtures/pluginsDuplicates/dummy.js | 35 ++ test/tsconfig.json | 2 +- test/unit/plugins/pluginManager.test.ts | 383 ++++++++++++++++++ ...se-body-universal-form-url-encoded.test.ts | 3 + .../relay-response-body-universal.test.ts | 2 + ...headers-universal-form-url-headers.test.ts | 2 + .../relay-response-headers-universal.test.ts | 2 + 21 files changed, 895 insertions(+), 8 deletions(-) create mode 100644 config.universal.json.sample create mode 100644 lib/client/brokerClientPlugins/abstractBrokerPlugin.ts create mode 100644 lib/client/brokerClientPlugins/pluginManager.ts create mode 100644 lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts create mode 100644 test/fixtures/plugins/dummy 3 plugin.js create mode 100644 test/fixtures/plugins/dummy 3 second plugin.js create mode 100644 test/fixtures/plugins/dummy.js create mode 100644 test/fixtures/plugins/dummy2.js create mode 100644 test/fixtures/pluginsDuplicates/dummy duplicate.js create mode 100644 test/fixtures/pluginsDuplicates/dummy.js create mode 100644 test/unit/plugins/pluginManager.test.ts diff --git a/config.universal.json.sample b/config.universal.json.sample new file mode 100644 index 000000000..3f2b4b9d2 --- /dev/null +++ b/config.universal.json.sample @@ -0,0 +1,15 @@ +{ + "BROKER_CLIENT_CONFIGURATION": { + "common": { + "default": { + "BROKER_SERVER_URL": "https://broker.pre-prod.snyk.io", + "BROKER_HA_MODE_ENABLED": "false", + "BROKER_DISPATCHER_BASE_URL": "https://api.pre-prod.snyk.io" + }, + "oauth": { + "clientId": "${CLIENT_ID}", + "clientSecret": "${CLIENT_SECRET}" + } + } + } +} diff --git a/lib/client/brokerClientPlugins/abstractBrokerPlugin.ts b/lib/client/brokerClientPlugins/abstractBrokerPlugin.ts new file mode 100644 index 000000000..5ce18f043 --- /dev/null +++ b/lib/client/brokerClientPlugins/abstractBrokerPlugin.ts @@ -0,0 +1,57 @@ +import { + HttpResponse, + makeRequestToDownstream, +} from '../../common/http/request'; +import { PostFilterPreparedRequest } from '../../common/relay/prepareRequest'; +import { log as logger } from '../../logs/logger'; + +export default abstract class BrokerPlugin { + abstract pluginCode: string; + abstract pluginName: string; + abstract description: string; + abstract version: string; + abstract applicableBrokerTypes: Array; + logger; + brokerClientConfiguration: Record; + makeRequestToDownstream: ( + req: PostFilterPreparedRequest, + retries?: any, + ) => Promise; + request?: PostFilterPreparedRequest; + + constructor(brokerClientCfg: Record) { + this.logger = logger; + this.brokerClientConfiguration = brokerClientCfg; + this.makeRequestToDownstream = makeRequestToDownstream; + } + + getApplicableTypes(): Array { + const applicableTypes: Array = []; + if ( + this.applicableBrokerTypes.every((type) => + this.brokerClientConfiguration.supportedBrokerTypes.includes(type), + ) + ) { + applicableTypes.push(...this.applicableBrokerTypes); + } + return applicableTypes; + } + isDisabled(config): boolean { + let isDisabled = false; + if (config[`DISABLE_${this.pluginCode}_PLUGIN`]) { + logger.info({ plugin: this.pluginName }, `Plugin disabled`); + isDisabled = true; + } + return isDisabled; + } + abstract isPluginActive(): boolean; + + abstract startUp(connectionConfiguration: Record): Promise; + + async preRequest( + connectionConfiguration: Record, + postFilterPreparedRequest: PostFilterPreparedRequest, + ): Promise { + return postFilterPreparedRequest; + } +} diff --git a/lib/client/brokerClientPlugins/pluginManager.ts b/lib/client/brokerClientPlugins/pluginManager.ts new file mode 100644 index 000000000..f75574c30 --- /dev/null +++ b/lib/client/brokerClientPlugins/pluginManager.ts @@ -0,0 +1,126 @@ +import { readdir } from 'fs/promises'; +import { log as logger } from '../../logs/logger'; +import BrokerPlugin from './abstractBrokerPlugin'; +import { existsSync } from 'fs'; +import { PostFilterPreparedRequest } from '../../common/relay/prepareRequest'; + +export const loadPlugins = async (pluginsFolderPath: string, clientOpts) => { + clientOpts.config['plugins'] = new Map(); + clientOpts.config.supportedBrokerTypes.forEach((type) => { + clientOpts.config.plugins.set(type, []); + }); + try { + if (existsSync(pluginsFolderPath)) { + const pluginsFiles = await readdir(pluginsFolderPath); + for (const pluginFile of pluginsFiles.filter((filename) => + filename.endsWith('.js'), + )) { + const plugin = await import(`${pluginsFolderPath}/${pluginFile}`); + // Passing the config object so we can mutate things like filters instead of READONLY + const pluginInstance = new plugin.Plugin(clientOpts.config); + const applicableBrokerTypes = pluginInstance.getApplicableTypes(); + applicableBrokerTypes.forEach((applicableBrokerType) => { + if ( + !pluginInstance.isDisabled(clientOpts.config) && + pluginInstance.isPluginActive() + ) { + logger.debug({}, `Loading plugin ${pluginInstance.pluginName}`); + const configPluginForCurrentType = + clientOpts.config.plugins.get(applicableBrokerType); + if ( + configPluginForCurrentType.some( + (x) => + x.pluginCode === pluginInstance.pluginCode || + x.pluginName === pluginInstance.pluginName, + ) + ) { + const errMsg = `Some Plugins have identical name or code.`; + logger.error({}, errMsg); + throw new Error(errMsg); + } + configPluginForCurrentType.push(pluginInstance); + } else { + logger.debug( + {}, + `Skipping plugin ${pluginInstance.pluginName}, not active.`, + ); + } + }); + } + } + return clientOpts.config['plugins']; + } catch (err) { + const errMsg = `Error loading plugins from ${pluginsFolderPath}`; + logger.error({ err }, `Error loading plugins from ${pluginsFolderPath}`); + throw new Error(errMsg); + } +}; + +export const runStartupPlugins = async (clientOpts) => { + const loadedPlugins = clientOpts.config.plugins as Map< + string, + BrokerPlugin[] + >; + const connectionsKeys = Object.keys(clientOpts.config.connections); + + for (const connectionKey of connectionsKeys) { + if ( + loadedPlugins.has(`${clientOpts.config.connections[connectionKey].type}`) + ) { + const pluginInstances = + loadedPlugins.get( + `${clientOpts.config.connections[connectionKey].type}`, + ) ?? []; + for (let i = 0; i < pluginInstances.length; i++) { + await pluginInstances[i].startUp( + clientOpts.config.connections[connectionKey], + ); + } + } + } +}; + +export const runPreRequestPlugins = async ( + clientOpts, + connectionIdentifier, + pristinePreRequest: PostFilterPreparedRequest, +) => { + let preRequest = pristinePreRequest; + const loadedPlugins = clientOpts.config.plugins as Map< + string, + BrokerPlugin[] + >; + const connectionsKeys = Object.keys(clientOpts.config.connections); + let connectionKey; + for (let i = 0; i < connectionsKeys.length; i++) { + if ( + clientOpts.config.connections[connectionsKeys[i]].identifier == + connectionIdentifier + ) { + connectionKey = connectionsKeys[i]; + break; + } + } + if (!connectionsKeys.includes(connectionKey)) { + const errMsg = `Plugin preRequest: connection ${connectionKey} not found`; + logger.error({ connectionKey }, errMsg); + throw new Error(errMsg); + } + + if ( + loadedPlugins.has(`${clientOpts.config.connections[connectionKey].type}`) + ) { + const pluginInstances = + loadedPlugins.get( + `${clientOpts.config.connections[connectionKey].type}`, + ) ?? []; + for (let i = 0; i < pluginInstances.length; i++) { + preRequest = await pluginInstances[i].preRequest( + clientOpts.config.connections[connectionKey], + preRequest, + ); + } + } + + return preRequest; +}; diff --git a/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts b/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts new file mode 100644 index 000000000..f599f9c69 --- /dev/null +++ b/lib/client/brokerClientPlugins/plugins/githubServerAppAuth.ts @@ -0,0 +1,60 @@ +// import { PostFilterPreparedRequest } from '../../../common/relay/prepareRequest'; +import BrokerPlugin from '../abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + // Plugin Code and Name must be unique across all plugins. + pluginCode = 'GITHUB_SERVER_APP'; + 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 + + // Provide a way to include specific conditional logic to execute + isPluginActive(): boolean { + // if (this.brokerClientConfiguration['XYZ']) { + // this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + // return false; + // } + return true; + } + + // 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'); + // } + } + + // Hook to run pre requests operations - Optional. Uncomment to enable + // async preRequest( + // connectionConfiguration: Record, + // postFilterPreparedRequest:PostFilterPreparedRequest, + // ) { + // this.logger.debug({ plugin: this.pluginName, connection: connectionConfiguration }, 'Running prerequest plugin'); + // return postFilterPreparedRequest; + // } +} diff --git a/lib/client/hooks/startup/processHooks.ts b/lib/client/hooks/startup/processHooks.ts index 3d2e7ae23..09bd7f1db 100644 --- a/lib/client/hooks/startup/processHooks.ts +++ b/lib/client/hooks/startup/processHooks.ts @@ -6,6 +6,7 @@ import { HookResults } from '../../types/client'; import { CheckResult } from '../../checks/types'; import { ClientOpts } from '../../../common/types/options'; import { highAvailabilityModeEnabled } from '../../config/configHelpers'; +import { runStartupPlugins } from '../../brokerClientPlugins/pluginManager'; export const validateMinimalConfig = async ( clientOpts: ClientOpts, @@ -134,6 +135,10 @@ export const processStartUpHooks = async ( ); } + if (clientOpts.config.universalBrokerEnabled) { + await runStartupPlugins(clientOpts); + } + return { preflightCheckResults: preflightCheckResults.length ? preflightCheckResults diff --git a/lib/client/index.ts b/lib/client/index.ts index 3950e3d7c..2f9e8c444 100644 --- a/lib/client/index.ts +++ b/lib/client/index.ts @@ -24,12 +24,14 @@ import { getClientConfigMetadata } from './config/configHelpers'; import { fetchJwt } from './auth/oauth'; import { CONFIGURATION, + findProjectRoot, getConfig, loadBrokerConfig, } from '../common/config/config'; import { retrieveConnectionsForDeployment } from './config/remoteConfig'; import { handleTerminationSignal } from '../common/utils/signals'; import { cleanUpUniversalFile } from './utils/cleanup'; +import { loadPlugins } from './brokerClientPlugins/pluginManager'; process.on('uncaughtException', (error) => { if (error.message == 'read ECONNRESET') { @@ -62,6 +64,15 @@ export const main = async (clientOpts: ClientOpts) => { 'https://api.snyk.io'; await validateMinimalConfig(clientOpts); + if (clientOpts.config.universalBrokerEnabled) { + const pluginsFolderPath = `${findProjectRoot( + __dirname, + )}/dist/lib/client/brokerClientPlugins/plugins`; + clientOpts.config.plugins = await loadPlugins( + pluginsFolderPath, + clientOpts, + ); + } if ( clientOpts.config.brokerClientConfiguration.common.oauth?.clientId && @@ -75,7 +86,7 @@ export const main = async (clientOpts: ClientOpts) => { ); await retrieveConnectionsForDeployment( clientOpts, - `${__dirname}/../../../../config.universal.json`, + `${findProjectRoot(process.cwd())}/config.universal.json`, ); // Reload config with connection await loadBrokerConfig(); @@ -87,6 +98,11 @@ export const main = async (clientOpts: ClientOpts) => { ) as Record as CONFIGURATION; handleTerminationSignal(cleanUpUniversalFile); } + + const brokerClientId = uuidv4(); + logger.info({ brokerClientId }, 'generated broker client id'); + const hookResults = await processStartUpHooks(clientOpts, brokerClientId); + const loadedClientOpts: LoadedClientOpts = { loadedFilters: loadAllFilters(clientOpts.filters, clientOpts.config), ...clientOpts, @@ -97,10 +113,6 @@ export const main = async (clientOpts: ClientOpts) => { throw new Error('Unable to load filters'); } - const brokerClientId = uuidv4(); - logger.info({ brokerClientId }, 'generated broker client id'); - const hookResults = await processStartUpHooks(clientOpts, brokerClientId); - const globalIdentifyingMetadata: IdentifyingMetadata = { capabilities: ['post-streams'], clientId: brokerClientId, diff --git a/lib/common/relay/forwardWebsocketRequest.ts b/lib/common/relay/forwardWebsocketRequest.ts index 72bdae2af..80e13ec2a 100644 --- a/lib/common/relay/forwardWebsocketRequest.ts +++ b/lib/common/relay/forwardWebsocketRequest.ts @@ -16,6 +16,7 @@ import { } from './requestsHelper'; import { LOADEDFILTERSET } from '../types/filter'; import { LoadedClientOpts, LoadedServerOpts } from '../types/options'; +import { runPreRequestPlugins } from '../../client/brokerClientPlugins/pluginManager'; export const forwardWebSocketRequest = ( options: LoadedClientOpts | LoadedServerOpts, @@ -209,6 +210,14 @@ export const forwardWebSocketRequest = ( connectionIdentifier, websocketConnectionHandler?.socketType, ); + if (options.config.universalBrokerEnabled) { + preparedRequest.req = await runPreRequestPlugins( + options, + connectionIdentifier, + preparedRequest.req, + ); + } + incrementHttpRequestsTotal(false, 'outbound-request'); payload.streamingID ? await makePostStreamingRequest(preparedRequest.req, emit, logContext) diff --git a/lib/common/relay/prepareRequest.ts b/lib/common/relay/prepareRequest.ts index d88b49d02..a58b78f9d 100644 --- a/lib/common/relay/prepareRequest.ts +++ b/lib/common/relay/prepareRequest.ts @@ -241,11 +241,13 @@ export const prepareRequestFromFilterResult = async ( logContext.requestHeaders = payload.headers; logger.debug(logContext, 'Prepared request'); - const req = { + const req: PostFilterPreparedRequest = { url: result.url, headers: payload.headers, method: payload.method, - body: payload.body, }; + if (payload.body) { + req.body = payload.body; + } return { req, error: errorPreparing }; }; diff --git a/lib/common/types/options.ts b/lib/common/types/options.ts index d09649066..4dcf3e29d 100644 --- a/lib/common/types/options.ts +++ b/lib/common/types/options.ts @@ -14,6 +14,7 @@ export interface ClientOpts { authHeader: string; expiresIn: number; }; + plugins?: Map; } export interface ServerOpts { diff --git a/test/fixtures/plugins/dummy 3 plugin.js b/test/fixtures/plugins/dummy 3 plugin.js new file mode 100644 index 000000000..d09e6dfbf --- /dev/null +++ b/test/fixtures/plugins/dummy 3 plugin.js @@ -0,0 +1,36 @@ +import BrokerPlugin from '../../../lib/client/brokerClientPlugins/abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + pluginCode = 'DUMMY3'; + pluginName = 'Dummy 3 Plugin'; + description = ` + dummy 3 plugin + `; + version = '0.1'; + applicableBrokerTypes = ['dummy3']; + + isPluginActive() { + if (this.brokerClientConfiguration.DISABLE_DUMMY_3_PLUGIN) { + this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + return false; + } + return true; + } + async startUp(connectionConfig) { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.info( + { config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + connectionConfig['NEW_VAR_ADDED_TO_CONNECTION_FROM_DUMMY3_PLUGIN'] = + 'access-token-dummy3'; + } + + async preRequest(connectionConfiguration, postFilterPreparedRequest) { + this.logger.info({ plugin: this.pluginName }, 'Running prerequest plugin'); + const customizedRequest = postFilterPreparedRequest; + customizedRequest.body['NEW_VAR_ADDED_TO_CONNECTION_FROM_DUMMY3_PLUGIN'] = + connectionConfiguration['NEW_VAR_ADDED_TO_CONNECTION_FROM_DUMMY3_PLUGIN']; + return customizedRequest; + } +} diff --git a/test/fixtures/plugins/dummy 3 second plugin.js b/test/fixtures/plugins/dummy 3 second plugin.js new file mode 100644 index 000000000..e16671102 --- /dev/null +++ b/test/fixtures/plugins/dummy 3 second plugin.js @@ -0,0 +1,40 @@ +import BrokerPlugin from '../../../lib/client/brokerClientPlugins/abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + pluginCode = 'SECONDDUMMY3'; + pluginName = 'Second Dummy 3 Plugin'; + description = ` + Second dummy 3 plugin + `; + version = '0.1'; + applicableBrokerTypes = ['dummy3']; + + isPluginActive() { + if (this.brokerClientConfiguration.DISABLE_SECOND_DUMMY_3_PLUGIN) { + this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + return false; + } + return true; + } + async startUp(connectionConfig) { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.info( + { config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + connectionConfig['NEW_VAR_ADDED_TO_CONNECTION_FROM_SECOND_DUMMY3_PLUGIN'] = + 'access-token-dummy3'; + } + + async preRequest(connectionConfiguration, postFilterPreparedRequest) { + this.logger.info({ plugin: this.pluginName }, 'Running prerequest plugin'); + const customizedRequest = postFilterPreparedRequest; + customizedRequest.body[ + 'NEW_VAR_ADDED_TO_CONNECTION_FROM_SECOND_DUMMY3_PLUGIN' + ] = + connectionConfiguration[ + 'NEW_VAR_ADDED_TO_CONNECTION_FROM_SECOND_DUMMY3_PLUGIN' + ]; + return customizedRequest; + } +} diff --git a/test/fixtures/plugins/dummy.js b/test/fixtures/plugins/dummy.js new file mode 100644 index 000000000..a91649641 --- /dev/null +++ b/test/fixtures/plugins/dummy.js @@ -0,0 +1,35 @@ +import BrokerPlugin from '../../../lib/client/brokerClientPlugins/abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + pluginCode = 'DUMMY'; + pluginName = 'Dummy Plugin'; + description = ` + dummy plugin + `; + version = '0.1'; + applicableBrokerTypes = ['dummy']; + + isPluginActive() { + if (this.brokerClientConfiguration.DISABLE_DUMMY_PLUGIN) { + this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + return false; + } + return true; + } + async startUp(connectionConfig) { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.info( + { config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + connectionConfig['NEW_VAR_ADDED_TO_CONNECTION'] = 'access-token'; + } + + async preRequest(connectionConfiguration, postFilterPreparedRequest) { + this.logger.info({ plugin: this.pluginName }, 'Running prerequest plugin'); + const customizedRequest = postFilterPreparedRequest; + customizedRequest.body['NEW_VAR_ADDED_TO_CONNECTION'] = + connectionConfiguration['NEW_VAR_ADDED_TO_CONNECTION']; + return customizedRequest; + } +} diff --git a/test/fixtures/plugins/dummy2.js b/test/fixtures/plugins/dummy2.js new file mode 100644 index 000000000..9502c1c51 --- /dev/null +++ b/test/fixtures/plugins/dummy2.js @@ -0,0 +1,27 @@ +import BrokerPlugin from '../../../lib/client/brokerClientPlugins/abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + pluginCode = 'DUMMY2'; + pluginName = 'Dummy Plugin 2'; + description = ` + dummy plugin 2 + `; + version = '0.1'; + applicableBrokerTypes = ['dummy2']; + + isPluginActive() { + if (this.brokerClientConfiguration.DISABLE_DUMMY_PLUGIN) { + this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + return false; + } + return true; + } + async startUp(connectionConfig) { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.info( + { config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + connectionConfig['NEW_VAR_ADDED_TO_CONNECTION_2'] = 'access-token'; + } +} diff --git a/test/fixtures/pluginsDuplicates/dummy duplicate.js b/test/fixtures/pluginsDuplicates/dummy duplicate.js new file mode 100644 index 000000000..a91649641 --- /dev/null +++ b/test/fixtures/pluginsDuplicates/dummy duplicate.js @@ -0,0 +1,35 @@ +import BrokerPlugin from '../../../lib/client/brokerClientPlugins/abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + pluginCode = 'DUMMY'; + pluginName = 'Dummy Plugin'; + description = ` + dummy plugin + `; + version = '0.1'; + applicableBrokerTypes = ['dummy']; + + isPluginActive() { + if (this.brokerClientConfiguration.DISABLE_DUMMY_PLUGIN) { + this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + return false; + } + return true; + } + async startUp(connectionConfig) { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.info( + { config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + connectionConfig['NEW_VAR_ADDED_TO_CONNECTION'] = 'access-token'; + } + + async preRequest(connectionConfiguration, postFilterPreparedRequest) { + this.logger.info({ plugin: this.pluginName }, 'Running prerequest plugin'); + const customizedRequest = postFilterPreparedRequest; + customizedRequest.body['NEW_VAR_ADDED_TO_CONNECTION'] = + connectionConfiguration['NEW_VAR_ADDED_TO_CONNECTION']; + return customizedRequest; + } +} diff --git a/test/fixtures/pluginsDuplicates/dummy.js b/test/fixtures/pluginsDuplicates/dummy.js new file mode 100644 index 000000000..a91649641 --- /dev/null +++ b/test/fixtures/pluginsDuplicates/dummy.js @@ -0,0 +1,35 @@ +import BrokerPlugin from '../../../lib/client/brokerClientPlugins/abstractBrokerPlugin'; + +export class Plugin extends BrokerPlugin { + pluginCode = 'DUMMY'; + pluginName = 'Dummy Plugin'; + description = ` + dummy plugin + `; + version = '0.1'; + applicableBrokerTypes = ['dummy']; + + isPluginActive() { + if (this.brokerClientConfiguration.DISABLE_DUMMY_PLUGIN) { + this.logger.debug({ plugin: this.pluginName }, 'Disabling plugin'); + return false; + } + return true; + } + async startUp(connectionConfig) { + this.logger.info({ plugin: this.pluginName }, 'Running Startup'); + this.logger.info( + { config: connectionConfig }, + 'Connection Config passed to the plugin', + ); + connectionConfig['NEW_VAR_ADDED_TO_CONNECTION'] = 'access-token'; + } + + async preRequest(connectionConfiguration, postFilterPreparedRequest) { + this.logger.info({ plugin: this.pluginName }, 'Running prerequest plugin'); + const customizedRequest = postFilterPreparedRequest; + customizedRequest.body['NEW_VAR_ADDED_TO_CONNECTION'] = + connectionConfiguration['NEW_VAR_ADDED_TO_CONNECTION']; + return customizedRequest; + } +} diff --git a/test/tsconfig.json b/test/tsconfig.json index a01274008..00641a4ec 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.json", - "include": ["**/*.ts", "unit/client/scm/github/commit.test.ts", "functional/systemcheck.test.ts", "unit/proxying-decision.test.ts", "functional/dispatcher-server-api.test.ts"] + "include": ["**/*.ts", "unit/client/scm/github/commit.test.ts", "functional/systemcheck.test.ts", "unit/proxying-decision.test.ts", "functional/dispatcher-server-api.test.ts", "fixtures/plugins/dummy.js"] } diff --git a/test/unit/plugins/pluginManager.test.ts b/test/unit/plugins/pluginManager.test.ts new file mode 100644 index 000000000..777e9f9e2 --- /dev/null +++ b/test/unit/plugins/pluginManager.test.ts @@ -0,0 +1,383 @@ +import { + loadPlugins, + runPreRequestPlugins, + runStartupPlugins, +} from '../../../lib/client/brokerClientPlugins/pluginManager'; +import { findProjectRoot } from '../../../lib/common/config/config'; +import { PostFilterPreparedRequest } from '../../../lib/common/relay/prepareRequest'; + +describe('Plugin Manager', () => { + const pluginsFolderPath = `${findProjectRoot( + __dirname, + )}/test/fixtures/plugins`; + + it('should load plugins successfully', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy'], + connections: { 'my connection': { type: 'dummy' } }, + }, + }; + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy')[0].pluginName).toEqual('Dummy Plugin'); + }); + + it('should load plugins no plugin successfully', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy4'], + connections: { 'my connection': { type: 'dummy4' } }, + }, + }; + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy4').length).toBeGreaterThanOrEqual(0); + }); + it('should not load plugins if disabled', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy'], + connections: { 'my connection': { type: 'dummy' } }, + }, + }; + clientOpts.config['DISABLE_DUMMY_PLUGIN'] = true; + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeLessThanOrEqual(0); + }); + it('should not load plugins if disabled', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy'], + connections: { 'my connection': { type: 'dummy' } }, + }, + }; + clientOpts.config['DISABLE_WHATEVER_PLUGIN'] = true; + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeGreaterThanOrEqual(1); + }); + + it('should run startup plugins successfully', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy'], + connections: { 'my connection': { type: 'dummy' } }, + }, + }; + try { + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy')[0].pluginName).toEqual('Dummy Plugin'); + + await runStartupPlugins(clientOpts); + expect( + clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ], + ).toEqual('access-token'); + } catch (err) { + // we should not error + expect(err).toBeNull(); + } + delete clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ]; + }); + + it('should run prerequest plugins successfully', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy'], + connections: { 'my connection': { type: 'dummy', identifier: '123' } }, + }, + }; + try { + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy')[0].pluginName).toEqual('Dummy Plugin'); + + await runStartupPlugins(clientOpts); + expect( + clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ], + ).toEqual('access-token'); + const requestDetails: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal = Object.assign({}, requestDetails.body); + const expectedRequestBody = Object.assign({}, requestDetails.body); + expectedRequestBody['NEW_VAR_ADDED_TO_CONNECTION'] = 'access-token'; + const request = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection'].identifier, + requestDetails, + ); + expect(request.body).not.toEqual(requestBodyDetailsOriginal); + expect(request.body).toEqual(expectedRequestBody); + } catch (err) { + // we should not error + expect(err).toBeNull(); + } + delete clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ]; + }); + + it('should run prerequest returning same request if not implemented', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy', 'dummy2'], + connections: { + 'my connection 2': { type: 'dummy2', identifier: '456' }, + }, + }, + }; + try { + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy2').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy2')[0].pluginName).toEqual('Dummy Plugin 2'); + + await runStartupPlugins(clientOpts); + expect( + clientOpts.config.connections['my connection 2'][ + 'NEW_VAR_ADDED_TO_CONNECTION_2' + ], + ).toEqual('access-token'); + const requestDetails: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal = Object.assign({}, requestDetails.body); + const request = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection 2'].identifier, + requestDetails, + ); + expect(request.body).toEqual(requestBodyDetailsOriginal); + } catch (err) { + // we should not error + expect(err).toBeNull(); + } + delete clientOpts.config.connections['my connection 2'][ + 'NEW_VAR_ADDED_TO_CONNECTION_2' + ]; + }); + + it('Multiple connections keeping things in their own swim lane', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy', 'dummy2'], + connections: { + 'my connection': { type: 'dummy', identifier: '123' }, + 'my connection 2': { type: 'dummy2', identifier: '456' }, + }, + }, + }; + try { + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy')[0].pluginName).toEqual('Dummy Plugin'); + expect(plugins.get('dummy2').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy2')[0].pluginName).toEqual('Dummy Plugin 2'); + + await runStartupPlugins(clientOpts); + expect( + clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ], + ).toEqual('access-token'); + expect( + clientOpts.config.connections['my connection 2'][ + 'NEW_VAR_ADDED_TO_CONNECTION_2' + ], + ).toEqual('access-token'); + + const requestDetails: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal = Object.assign({}, requestDetails.body); + const expectedRequestBody = Object.assign({}, requestDetails.body); + expectedRequestBody['NEW_VAR_ADDED_TO_CONNECTION'] = 'access-token'; + const request = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection'].identifier, + requestDetails, + ); + expect(request.body).not.toEqual(requestBodyDetailsOriginal); + expect(request.body).toEqual(expectedRequestBody); + + const requestDetails2: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal2 = Object.assign( + {}, + requestDetails2.body, + ); + const request2 = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection 2'].identifier, + requestDetails2, + ); + expect(request2.body).toEqual(requestBodyDetailsOriginal2); + } catch (err) { + // we should not error + expect(err).toBeNull(); + } + delete clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ]; + delete clientOpts.config.connections['my connection 2'][ + 'NEW_VAR_ADDED_TO_CONNECTION_2' + ]; + }); + + it('Multiple connections and multiple plugins for a given type keep things in their own swim lane', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy', 'dummy2', 'dummy3'], + connections: { + 'my connection': { type: 'dummy', identifier: '123' }, + 'my connection 2': { type: 'dummy2', identifier: '456' }, + 'my connection 3': { type: 'dummy3', identifier: '789' }, + }, + }, + }; + try { + const plugins = await loadPlugins(pluginsFolderPath, clientOpts); + expect(plugins.get('dummy').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy')[0].pluginName).toEqual('Dummy Plugin'); + expect(plugins.get('dummy2').length).toBeGreaterThanOrEqual(1); + expect(plugins.get('dummy2')[0].pluginName).toEqual('Dummy Plugin 2'); + expect(plugins.get('dummy3').length).toBeGreaterThanOrEqual(2); + expect(plugins.get('dummy3')[0].pluginName).toEqual('Dummy 3 Plugin'); + expect(plugins.get('dummy3')[1].pluginName).toEqual( + 'Second Dummy 3 Plugin', + ); + + await runStartupPlugins(clientOpts); + expect( + clientOpts.config.connections['my connection'][ + 'NEW_VAR_ADDED_TO_CONNECTION' + ], + ).toEqual('access-token'); + expect( + clientOpts.config.connections['my connection 2'][ + 'NEW_VAR_ADDED_TO_CONNECTION_2' + ], + ).toEqual('access-token'); + expect( + clientOpts.config.connections['my connection 3'][ + 'NEW_VAR_ADDED_TO_CONNECTION_FROM_DUMMY3_PLUGIN' + ], + ).toEqual('access-token-dummy3'); + expect( + clientOpts.config.connections['my connection 3'][ + 'NEW_VAR_ADDED_TO_CONNECTION_FROM_SECOND_DUMMY3_PLUGIN' + ], + ).toEqual('access-token-dummy3'); + + const requestDetails: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal = Object.assign({}, requestDetails.body); + const expectedRequestBody = Object.assign({}, requestDetails.body); + expectedRequestBody['NEW_VAR_ADDED_TO_CONNECTION'] = 'access-token'; + const request = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection'].identifier, + requestDetails, + ); + expect(request.body).not.toEqual(requestBodyDetailsOriginal); + expect(request.body).toEqual(expectedRequestBody); + + const requestDetails2: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal2 = Object.assign( + {}, + requestDetails2.body, + ); + const request2 = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection 2'].identifier, + requestDetails2, + ); + expect(request2.body).toEqual(requestBodyDetailsOriginal2); + + const requestDetails3: PostFilterPreparedRequest = { + url: 'http://bla', + headers: { myHeader: 'my_value' }, + method: 'POST', + body: { myField: 'my field value' }, + }; + const requestBodyDetailsOriginal3 = Object.assign( + {}, + requestDetails3.body, + ); + const expectedRequestBody3 = Object.assign({}, requestDetails3.body); + expectedRequestBody3['NEW_VAR_ADDED_TO_CONNECTION_FROM_DUMMY3_PLUGIN'] = + 'access-token-dummy3'; + expectedRequestBody3[ + 'NEW_VAR_ADDED_TO_CONNECTION_FROM_SECOND_DUMMY3_PLUGIN' + ] = 'access-token-dummy3'; + const request3 = await runPreRequestPlugins( + clientOpts, + clientOpts.config.connections['my connection 3'].identifier, + requestDetails3, + ); + expect(request3).not.toEqual(requestBodyDetailsOriginal3); + expect(request3.body).toEqual(expectedRequestBody3); + } catch (err) { + // we should not error + expect(err).toBeNull(); + } + }); +}); + +describe('Plugin Manager', () => { + const pluginsFolderPath = `${findProjectRoot( + __dirname, + )}/test/fixtures/pluginsDuplicates`; + + it('should fail loading plugins if same name', async () => { + const clientOpts = { + config: { + universalBrokerEnabled: true, + supportedBrokerTypes: ['dummy'], + connections: { 'my connection': { type: 'dummy' } }, + }, + }; + try { + await loadPlugins(pluginsFolderPath, clientOpts); + expect(false).toBeTruthy(); + } catch (err) { + expect(err).not.toBeNull(); + } + }); +}); diff --git a/test/unit/relay-response-body-universal-form-url-encoded.test.ts b/test/unit/relay-response-body-universal-form-url-encoded.test.ts index 157de8fe5..68c222e0b 100644 --- a/test/unit/relay-response-body-universal-form-url-encoded.test.ts +++ b/test/unit/relay-response-body-universal-form-url-encoded.test.ts @@ -74,6 +74,7 @@ describe('body relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, @@ -139,6 +140,7 @@ describe('body relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, @@ -197,6 +199,7 @@ describe('body relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, diff --git a/test/unit/relay-response-body-universal.test.ts b/test/unit/relay-response-body-universal.test.ts index 744ed5a15..99d82ed87 100644 --- a/test/unit/relay-response-body-universal.test.ts +++ b/test/unit/relay-response-body-universal.test.ts @@ -84,6 +84,7 @@ describe('body relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), brokerType: 'client', connections: { myconn: { @@ -152,6 +153,7 @@ describe('body relay', () => { const config = { disableBodyVarsSubstitution: true, universalBrokerEnabled: true, + plugins: new Map(), brokerType: 'client', connections: { myconn: { diff --git a/test/unit/relay-response-headers-universal-form-url-headers.test.ts b/test/unit/relay-response-headers-universal-form-url-headers.test.ts index e976da634..7310f0790 100644 --- a/test/unit/relay-response-headers-universal-form-url-headers.test.ts +++ b/test/unit/relay-response-headers-universal-form-url-headers.test.ts @@ -71,6 +71,7 @@ describe('header relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, @@ -136,6 +137,7 @@ describe('header relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, diff --git a/test/unit/relay-response-headers-universal.test.ts b/test/unit/relay-response-headers-universal.test.ts index 755c1607e..ebdff40af 100644 --- a/test/unit/relay-response-headers-universal.test.ts +++ b/test/unit/relay-response-headers-universal.test.ts @@ -71,6 +71,7 @@ describe('header relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, @@ -130,6 +131,7 @@ describe('header relay', () => { const config = { universalBrokerEnabled: true, + plugins: new Map(), connections: { myconn: { identifier: brokerToken, From 9bf4293043d169192bb3d746149d3c01f79ce955 Mon Sep 17 00:00:00 2001 From: Antoine Arlaud Date: Wed, 15 May 2024 11:05:59 +0200 Subject: [PATCH 2/3] fix: mv rules to debug log and rm ws auth header --- lib/client/socket.ts | 8 ++++---- lib/client/socketHandlers/openHandler.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/client/socket.ts b/lib/client/socket.ts index 2c96799fe..874e3636d 100644 --- a/lib/client/socket.ts +++ b/lib/client/socket.ts @@ -139,10 +139,10 @@ export const createWebSocket = ( clientOpts.config.brokerClientConfiguration.common.oauth!.clientSecret, ); - websocket.transport.extraHeaders['Authorization'] = - clientOpts.accessToken!.authHeader; - websocket.end(); - websocket.open(); + // websocket.transport.extraHeaders['Authorization'] = + // clientOpts.accessToken!.authHeader; + // websocket.end(); + // websocket.open(); timeoutHandlerId = setTimeout( timeoutHandler, (clientOpts.accessToken!.expiresIn - 60) * 1000, diff --git a/lib/client/socketHandlers/openHandler.ts b/lib/client/socketHandlers/openHandler.ts index ca6498902..8e61f1281 100644 --- a/lib/client/socketHandlers/openHandler.ts +++ b/lib/client/socketHandlers/openHandler.ts @@ -24,7 +24,7 @@ export const openHandler = ( metadata['supportedIntegrationType'] = identifyingMetadata.supportedIntegrationType; } - logger.info( + logger.debug( { url: clientOps.config.brokerServerUrl, serverId: identifyingMetadata.serverId ?? clientOps.config.serverId ?? '', From cb7da75d8c0221745f0aec84de82e5f43b74b42b Mon Sep 17 00:00:00 2001 From: Antoine Arlaud Date: Wed, 15 May 2024 12:29:56 +0200 Subject: [PATCH 3/3] feat: add github-server-app plugin --- config.default.json | 4 + defaultFilters/github-server-app.json | 16 +- .../brokerClientPlugins/pluginManager.ts | 5 +- .../plugins/githubServerAppAuth.ts | 228 +++++++++++++++--- package-lock.json | 99 ++++++++ package.json | 2 + .../plugins/github-server-app/dummy.pem | 28 +++ test/helpers/utils.ts | 5 + .../brokerPlugins/github-server-app.test.ts | 138 +++++++++++ 9 files changed, 495 insertions(+), 30 deletions(-) create mode 100644 test/fixtures/plugins/github-server-app/dummy.pem create mode 100644 test/helpers/utils.ts create mode 100644 test/unit/plugins/brokerPlugins/github-server-app.test.ts 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']); + }); +});