diff --git a/.env.example b/.env.example index 2025622dc0..29d63ac9b3 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,6 @@ SMTP_PASSWORD= # Integration # Optional only if integration is used OAUTH_CLIENT_SECRET_HEROKU= -OAUTH_TOKEN_URL_HEROKU= # Sentry (optional) for monitoring errors SENTRY_DSN= diff --git a/backend/src/controllers/botController.ts b/backend/src/controllers/botController.ts index d4bc90cf3d..7819e32df4 100644 --- a/backend/src/controllers/botController.ts +++ b/backend/src/controllers/botController.ts @@ -18,7 +18,7 @@ interface BotKey { export const getBotByWorkspaceId = async (req: Request, res: Response) => { let bot; try { - const { workspaceId } = req.body; + const { workspaceId } = req.params; bot = await Bot.findOne({ workspace: workspaceId @@ -58,13 +58,24 @@ export const setBotActiveState = async (req: Request, res: Response) => { if (isActive) { // bot state set to active -> share workspace key with bot - await new BotKey({ + if (!botKey?.encryptedKey || !botKey?.nonce) { + return res.status(400).send({ + message: 'Failed to set bot state to active - missing bot key' + }); + } + + await BotKey.findOneAndUpdate({ + workspace: req.bot.workspace + }, { encryptedKey: botKey.encryptedKey, nonce: botKey.nonce, sender: req.user._id, - receiver: req.bot._id, + bot: req.bot._id, workspace: req.bot.workspace - }).save(); + }, { + upsert: true, + new: true + }); } else { // case: bot state set to inactive -> delete bot's workspace key await BotKey.deleteOne({ diff --git a/backend/src/controllers/integrationAuthController.ts b/backend/src/controllers/integrationAuthController.ts index fcdb404952..829abc7c24 100644 --- a/backend/src/controllers/integrationAuthController.ts +++ b/backend/src/controllers/integrationAuthController.ts @@ -3,15 +3,13 @@ import * as Sentry from '@sentry/node'; import axios from 'axios'; import { readFileSync } from 'fs'; import { IntegrationAuth, Integration } from '../models'; -import { processOAuthTokenRes } from '../helpers/integrationAuth'; import { INTEGRATION_SET, ENV_DEV } from '../variables'; import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; - import { IntegrationService } from '../services'; +import { getApps } from '../integrations'; /** * Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] - * Note: integration [integration] must be set up compatible/designed for OAuth2 * @param req * @param res * @returns @@ -21,8 +19,6 @@ export const oAuthExchange = async ( res: Response ) => { try { - // let clientSecret; - const { workspaceId, code, integration } = req.body; if (!INTEGRATION_SET.has(integration)) @@ -33,42 +29,6 @@ export const oAuthExchange = async ( integration, code }); - - // // use correct client secret - // switch (integration) { - // case 'heroku': - // clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - // } - - // // TODO: unfinished - make compatible with other integration types - // const res = await axios.post( // this response may be different for each integration - // OAUTH_TOKEN_URL_HEROKU!, - // new URLSearchParams({ - // grant_type: 'authorization_code', - // code: code, - // client_secret: clientSecret - // } as any) - // ); - - // const integrationAuth = await processOAuthTokenRes({ - // workspaceId, - // integration, - // res - // }); - - // // create or replace integration - // const integrationObj = await Integration.findOneAndUpdate( - // { workspace: workspaceId, integration }, - // { - // workspace: workspaceId, - // environment: ENV_DEV, - // isActive: false, - // app: null, - // integration, - // integrationAuth: integrationAuth._id - // }, - // { upsert: true, new: true } - // ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -83,26 +43,25 @@ export const oAuthExchange = async ( }; /** - * Return list of applications allowed for integration with id [integrationAuthId] + * Return list of applications allowed for integration with integration authorization id [integrationAuthId] * @param req * @param res * @returns */ export const getIntegrationAuthApps = async (req: Request, res: Response) => { - // TODO: unfinished - make compatible with other integration types let apps; try { - const res = await axios.get('https://api.heroku.com/apps', { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: 'Bearer ' + req.accessToken - } + apps = await getApps({ + integration: req.integrationAuth.integration, + accessToken: req.accessToken }); - - apps = res.data.map((a: any) => ({ - name: a.name - })); - } catch (err) {} + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to get integration authorization applications' + }); + } return res.status(200).send({ apps diff --git a/backend/src/controllers/integrationController.ts b/backend/src/controllers/integrationController.ts index 3c6ebba569..df3fa5026b 100644 --- a/backend/src/controllers/integrationController.ts +++ b/backend/src/controllers/integrationController.ts @@ -49,7 +49,6 @@ export const getIntegrations = async (req: Request, res: Response) => { }); }; -// TODO: deprecate /** * Sync secrets [secrets] to integration with id [integrationId] * @param req @@ -57,7 +56,10 @@ export const getIntegrations = async (req: Request, res: Response) => { * @returns */ export const syncIntegration = async (req: Request, res: Response) => { - // TODO: unfinished - make more versatile to accomodate for other integrations + // NOTE TO ALL DEVS: THIS FUNCTION IS BEING DEPRECATED. IGNORE IT BUT KEEP IT FOR NOW. + + return; + try { const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body; const symmetricKey = decryptAsymmetric({ @@ -106,6 +108,7 @@ export const syncIntegration = async (req: Request, res: Response) => { */ export const modifyIntegration = async (req: Request, res: Response) => { let integration; + try { const { update } = req.body; diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index e66fe4489d..3235a488d5 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -1,9 +1,20 @@ import * as Sentry from '@sentry/node'; import { - Bot + Bot, + BotKey, + Secret, + ISecret, + IUser } from '../models'; -import { generateKeyPair, encryptSymmetric } from '../utils/crypto'; +import { + generateKeyPair, + encryptSymmetric, + decryptSymmetric, + decryptAsymmetric +} from '../utils/crypto'; +import { decryptSecrets } from '../helpers/secret'; import { ENCRYPTION_KEY } from '../config'; +import { SECRET_SHARED } from '../variables'; /** * Create an inactive bot with name [name] for workspace with id [workspaceId] @@ -44,6 +55,175 @@ const createBot = async ({ return bot; } +/** + * Return decrypted secrets for workspace with id [workspaceId] + * and [environment] using bot + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.environment - environment + */ +const getSecretsHelper = async ({ + workspaceId, + environment +}: { + workspaceId: string; + environment: string; +}) => { + let content = {} as any; + try { + const key = await getKey({ workspaceId }); + const secrets = await Secret.find({ + workspaceId, + type: SECRET_SHARED + }); + + secrets.forEach((secret: ISecret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + + content[secretKey] = secretValue; + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get secrets'); + } + + return content; +} + +/** + * Return bot's copy of the workspace key for workspace + * with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @returns {String} key - decrypted workspace key + */ +const getKey = async ({ workspaceId }: { workspaceId: string }) => { + let key; + try { + const botKey = await BotKey.findOne({ + workspace: workspaceId + }).populate<{ sender: IUser }>('sender', 'publicKey'); + + if (!botKey) throw new Error('Failed to find bot key'); + + const bot = await Bot.findOne({ + workspace: workspaceId + }).select('+encryptedPrivateKey +iv +tag'); + + if (!bot) throw new Error('Failed to find bot'); + if (!bot.isActive) throw new Error('Bot is not active'); + + const privateKeyBot = decryptSymmetric({ + ciphertext: bot.encryptedPrivateKey, + iv: bot.iv, + tag: bot.tag, + key: ENCRYPTION_KEY + }); + + key = decryptAsymmetric({ + ciphertext: botKey.encryptedKey, + nonce: botKey.nonce, + publicKey: botKey.sender.publicKey as string, + privateKey: privateKeyBot + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get workspace key'); + } + + return key; +} + +/** + * Return symmetrically encrypted [plaintext] using the + * key for workspace with id [workspaceId] + * @param {Object} obj1 + * @param {String} obj1.workspaceId - id of workspace + * @param {String} obj1.plaintext - plaintext to encrypt + */ +const encryptSymmetricHelper = async ({ + workspaceId, + plaintext +}: { + workspaceId: string; + plaintext: string; +}) => { + + try { + const key = await getKey({ workspaceId }); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext, + key + }); + + return ({ + ciphertext, + iv, + tag + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to perform symmetric encryption with bot'); + } +} +/** + * Return symmetrically decrypted [ciphertext] using the + * key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - iv + * @param {String} obj.tag - tag + */ +const decryptSymmetricHelper = async ({ + workspaceId, + ciphertext, + iv, + tag +}: { + workspaceId: string; + ciphertext: string; + iv: string; + tag: string; +}) => { + let plaintext; + try { + const key = await getKey({ workspaceId }); + const plaintext = decryptSymmetric({ + ciphertext, + iv, + tag, + key + }); + + return plaintext; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to perform symmetric decryption with bot'); + } + + return plaintext; +} + export { - createBot + createBot, + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper } \ No newline at end of file diff --git a/backend/src/helpers/event.ts b/backend/src/helpers/event.ts new file mode 100644 index 0000000000..4128752e54 --- /dev/null +++ b/backend/src/helpers/event.ts @@ -0,0 +1,51 @@ +import { Bot, IBot } from '../models'; +import * as Sentry from '@sentry/node'; +import { EVENT_PUSH_SECRETS } from '../variables'; +import { IntegrationService } from '../services'; + +interface Event { + name: string; + workspaceId: string; + payload: any; +} + +/** + * Handle event [event] + * @param {Object} obj + * @param {Event} obj.event - an event + * @param {String} obj.event.name - name of event + * @param {String} obj.event.workspaceId - id of workspace that event is part of + * @param {Object} obj.event.payload - payload of event (depends on event) + */ +const handleEventHelper = async ({ + event +}: { + event: Event; +}) => { + const { workspaceId } = event; + + // TODO: moduralize bot check into separate function + const bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) return; + + try { + switch (event.name) { + case EVENT_PUSH_SECRETS: + IntegrationService.syncIntegrations({ + workspaceId + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + } +} + +export { + handleEventHelper +} \ No newline at end of file diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index e69de29bb2..c4caeec9db 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -0,0 +1,324 @@ +import * as Sentry from '@sentry/node'; +import { + Bot, + Integration, + IIntegration, + IntegrationAuth, + IIntegrationAuth +} from '../models'; +import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; +import { BotService, IntegrationService } from '../services'; +import { + ENV_DEV, + EVENT_PUSH_SECRETS +} from '../variables'; + +/** + * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration + * named [integration] + * - Store integration access and refresh tokens returned from the OAuth2 code-token exchange + * - Add placeholder inactive integration + * - Create bot sequence for integration + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.integration - name of integration + * @param {String} obj.code - code +*/ +const handleOAuthExchangeHelper = async ({ + workspaceId, + integration, + code +}: { + workspaceId: string; + integration: string; + code: string; +}) => { + let action; + let integrationAuth; + try { + const bot = await Bot.findOne({ + workspace: workspaceId, + isActive: true + }); + + if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); + + // exchange code for access and refresh tokens + let res = await exchangeCode({ + integration, + code + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + workspace: workspaceId, + integration + }, { + workspace: workspaceId, + integration + }, { + new: true, + upsert: true + }); + + // set integration auth refresh token + await setIntegrationAuthRefreshHelper({ + integrationAuthId: integrationAuth._id.toString(), + refreshToken: res.refreshToken + }); + + // set integration auth access token + await setIntegrationAuthAccessHelper({ + integrationAuthId: integrationAuth._id.toString(), + accessToken: res.accessToken, + accessExpiresAt: res.accessExpiresAt + }); + + // initializes an integration after exchange + await Integration.findOneAndUpdate( + { workspace: workspaceId, integration }, + { + workspace: workspaceId, + environment: ENV_DEV, + isActive: false, + app: null, + integration, + integrationAuth: integrationAuth._id + }, + { upsert: true, new: true } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to handle OAuth2 code-token exchange') + } +} +/** + * Sync/push environment variables in workspace with id [workspaceId] to + * all active integrations for that workspace + * @param {Object} obj + * @param {Object} obj.workspaceId - id of workspace + */ +const syncIntegrationsHelper = async ({ + workspaceId +}: { + workspaceId: string; +}) => { + let integrations; + try { + integrations = await Integration.find({ + workspace: workspaceId, + isActive: true, // TODO: filter so Integrations are ones with non-null apps + app: { $ne: null } + }).populate<{integrationAuth: IIntegrationAuth}>('integrationAuth', 'accessToken'); + + // for each workspace integration, sync/push secrets + // to that integration + for await (const integration of integrations) { + // get workspace, environment (shared) secrets + const secrets = await BotService.getSecrets({ + workspaceId: integration.workspace.toString(), + environment: integration.environment + }); + + // get integration auth access token + const accessToken = await getIntegrationAuthAccessHelper({ + integrationAuthId: integration.integrationAuth._id.toString() + }); + + // sync secrets to integration + await syncSecrets({ + integration: integration.integration, + app: integration.app, + secrets, + accessToken + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to integrations'); + } +} + +/** + * Return decrypted refresh token using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} refreshToken - decrypted refresh token + */ + const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + let refreshToken; + try { + const integrationAuth = await IntegrationAuth + .findById(integrationAuthId) + .select('+refreshCiphertext +refreshIV +refreshTag'); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + refreshToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + ciphertext: integrationAuth.refreshCiphertext as string, + iv: integrationAuth.refreshIV as string, + tag: integrationAuth.refreshTag as string + }); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration refresh token'); + } + + return refreshToken; +} + +/** + * Return decrypted access token using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @returns {String} accessToken - decrypted access token + */ +const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => { + let accessToken; + try { + const integrationAuth = await IntegrationAuth + .findById(integrationAuthId) + .select('+accessCiphertext +accessIV +accessTag'); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + accessToken = await BotService.decryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + ciphertext: integrationAuth.accessCiphertext as string, + iv: integrationAuth.accessIV as string, + tag: integrationAuth.accessTag as string + }); + + if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) { + // there is a access token expiration date + // and refresh token to exchange with the OAuth2 server + + if (integrationAuth.accessExpiresAt < new Date()) { + // access token is expired + const refreshToken = await getIntegrationAuthRefreshHelper({ integrationAuthId }); + accessToken = await exchangeRefresh({ + integration: integrationAuth.integration, + refreshToken + }); + } + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration access token'); + } + + return accessToken; +} + +/** + * Encrypt refresh token [refreshToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] and store it + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.refreshToken - refresh token + */ +const setIntegrationAuthRefreshHelper = async ({ + integrationAuthId, + refreshToken +}: { + integrationAuthId: string; + refreshToken: string; +}) => { + + let integrationAuth; + try { + integrationAuth = await IntegrationAuth + .findById(integrationAuthId); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + const obj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + plaintext: refreshToken + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + _id: integrationAuthId + }, { + refreshCiphertext: obj.ciphertext, + refreshIV: obj.iv, + refreshTag: obj.tag + }, { + new: true + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to set integration auth refresh token'); + } + + return integrationAuth; +} + +/** + * Encrypt access token [accessToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] and store it along with [accessExpiresAt] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.accessToken - access token + * @param {Date} obj.accessExpiresAt - expiration date of access token + */ +const setIntegrationAuthAccessHelper = async ({ + integrationAuthId, + accessToken, + accessExpiresAt +}: { + integrationAuthId: string; + accessToken: string; + accessExpiresAt: Date; +}) => { + let integrationAuth; + try { + integrationAuth = await IntegrationAuth.findById(integrationAuthId); + + if (!integrationAuth) throw new Error('Failed to find integration auth'); + + const obj = await BotService.encryptSymmetric({ + workspaceId: integrationAuth.workspace.toString(), + plaintext: accessToken + }); + + integrationAuth = await IntegrationAuth.findOneAndUpdate({ + _id: integrationAuthId + }, { + accessCiphertext: obj.ciphertext, + accessIV: obj.iv, + accessTag: obj.tag, + accessExpiresAt + }, { + new: true + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to save integration auth access token'); + } + + return integrationAuth; +} + +export { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper +} \ No newline at end of file diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/helpers/integrationAuth.ts index dddad5c8a8..e69de29bb2 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/helpers/integrationAuth.ts @@ -1,250 +0,0 @@ -import * as Sentry from '@sentry/node'; -import axios from 'axios'; -import { IntegrationAuth } from '../models'; -import { encryptSymmetric, decryptSymmetric } from '../utils/crypto'; -import { IIntegrationAuth } from '../models'; -import { - ENCRYPTION_KEY, - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU -} from '../config'; - -/** - * Encrypt access and refresh tokens, compute new access token expiration times [accessExpiresAt], - * and upsert them into the DB for workspace with id [workspaceId] and integration [integration]. - * @param {Object} obj - * @param {String} obj.workspaceId - id of workspace - * @param {String} obj.integration - name of integration - * @param {String} obj.accessToken - access token for integration - * @param {Date} obj.accessExpiresAt - date of expiration for access token - * @param {String} obj.refreshToken - refresh token for integration -*/ -const processOAuthTokenRes2 = async ({ - workspaceId, - integration, - accessToken, - accessExpiresAt, - refreshToken, -}: { - workspaceId: string; - integration: string; - accessToken: string; - accessExpiresAt: Date; - refreshToken: string; -}) => { - - let integrationAuth; - try { - // encrypt refresh + access tokens - const { - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag - } = encryptSymmetric({ - plaintext: refreshToken, - key: ENCRYPTION_KEY - }); - - const { - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag - } = encryptSymmetric({ - plaintext: accessToken, - key: ENCRYPTION_KEY - }); - - // create or replace integration authorization with encrypted tokens - // and access token expiration date - integrationAuth = await IntegrationAuth.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - integration, - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - }, - { upsert: true, new: true } - ); - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error( - 'Failed to process OAuth2 authorization server token response' - ); - } - - return integrationAuth; -} - -// TODO: deprecate -/** - * Process token exchange and refresh responses from respective OAuth2 authorization servers by - * encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt], - * and upserting them into the DB for workspace with id [workspaceId] and integration [integration]. - * @param {Object} obj - * @param {String} obj.workspaceId - id of workspace - * @param {String} obj.integration - name of integration (e.g. heroku) - * @param {Object} obj.res - response from OAuth2 authorization server - */ -const processOAuthTokenRes = async ({ - workspaceId, - integration, - res -}: { - workspaceId: string; - integration: string; - res: any; -}): Promise => { - let integrationAuth; - try { - // encrypt refresh + access tokens - const { - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag - } = encryptSymmetric({ - plaintext: res.data.refresh_token, - key: ENCRYPTION_KEY - }); - - const { - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag - } = encryptSymmetric({ - plaintext: res.data.access_token, - key: ENCRYPTION_KEY - }); - - // compute access token expiration date - const accessExpiresAt = new Date(); - accessExpiresAt.setSeconds( - accessExpiresAt.getSeconds() + res.data.expires_in - ); - - // create or replace integration authorization with encrypted tokens - // and access token expiration date - integrationAuth = await IntegrationAuth.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - integration, - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - }, - { upsert: true, new: true } - ); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error( - 'Failed to process OAuth2 authorization server token response' - ); - } - - return integrationAuth; -}; - -// TODO: deprecate -/** - * Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on - * the integration authorization document or by requesting a new one by decrypting and exchanging the - * refresh token [refreshCiphertext] with the respective OAuth2 authorization server. - * @param {Object} obj - * @param {IIntegrationAuth} obj.integrationAuth - an integration authorization document - * @returns {String} access token - new access token - */ -const getOAuthAccessToken = async ({ - integrationAuth -}: { - integrationAuth: IIntegrationAuth; -}) => { - let accessToken; - try { - const { - refreshCiphertext, - refreshIV, - refreshTag, - accessCiphertext, - accessIV, - accessTag, - accessExpiresAt - } = integrationAuth; - - if ( - refreshCiphertext && - refreshIV && - refreshTag && - accessCiphertext && - accessIV && - accessTag && - accessExpiresAt - ) { - if (accessExpiresAt < new Date()) { - // case: access token expired - // TODO: fetch another access token - - let clientSecret; - switch (integrationAuth.integration) { - case 'heroku': - clientSecret = OAUTH_CLIENT_SECRET_HEROKU; - } - - // record new access token and refresh token - // encrypt refresh + access tokens - const refreshToken = decryptSymmetric({ - ciphertext: refreshCiphertext, - iv: refreshIV, - tag: refreshTag, - key: ENCRYPTION_KEY - }); - - // TODO: make route compatible with other integration types - const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU, // maybe shouldn't be a config variable? - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_secret: clientSecret - } as any) - ); - - accessToken = res.data.access_token; - - await processOAuthTokenRes({ - workspaceId: integrationAuth.workspace.toString(), - integration: integrationAuth.integration, - res - }); - } else { - // case: access token still works - accessToken = decryptSymmetric({ - ciphertext: accessCiphertext, - iv: accessIV, - tag: accessTag, - key: ENCRYPTION_KEY - }); - } - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get OAuth2 access token'); - } - - return accessToken; -}; - -export { processOAuthTokenRes, processOAuthTokenRes2, getOAuthAccessToken }; diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 34d5a53e73..b43252bf38 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -30,10 +30,10 @@ const createWorkspace = async ({ organization: organizationId }).save(); - // const bot = await createBot({ - // name: 'Infisical Bot', - // workspaceId: workspace._id.toString() - // }); + const bot = await createBot({ + name: 'Infisical Bot', + workspaceId: workspace._id.toString() + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts new file mode 100644 index 0000000000..c40d9f5ee5 --- /dev/null +++ b/backend/src/integrations/apps.ts @@ -0,0 +1,77 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { + INTEGRATION_HEROKU, + INTEGRATION_HEROKU_APPS_URL +} from '../variables'; + +/** + * Return list of names of apps for integration named [integration] + * @param {Object} obj + * @param {String} obj.integration - name of integration + * @param {String} obj.accessToken - access token for integration + * @returns {Object[]} apps - names of integration apps + * @returns {String} apps.name - name of integration app + */ +const getApps = async ({ + integration, + accessToken +}: { + integration: string; + accessToken: string; +}) => { + let apps; + try { + switch (integration) { + case INTEGRATION_HEROKU: + apps = await getAppsHeroku({ + accessToken + }); + break; + } + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get integration apps'); + } + + return apps; +} + +/** + * Return list of names of apps for Heroku integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Heroku API + * @returns {Object[]} apps - names of Heroku apps + * @returns {String} apps.name - name of Heroku app + */ +const getAppsHeroku = async ({ + accessToken +}: { + accessToken: string; +}) => { + let apps; + try { + const res = await axios.get(INTEGRATION_HEROKU_APPS_URL, { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + }); + + apps = res.data.map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Heroku integration apps'); + } + + return apps; +} + +export { + getApps +} \ No newline at end of file diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 96ca8d26d8..1452c4e2c3 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -2,11 +2,11 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU, + INTEGRATION_HEROKU_TOKEN_URL, ACTION_PUSH_TO_HEROKU } from '../variables'; import { - OAUTH_CLIENT_SECRET_HEROKU, - OAUTH_TOKEN_URL_HEROKU + OAUTH_CLIENT_SECRET_HEROKU } from '../config'; /** @@ -29,13 +29,13 @@ const exchangeCode = async ({ code: string; }) => { let obj = {} as any; + try { switch (integration) { case INTEGRATION_HEROKU: obj = await exchangeCodeHeroku({ code }); - obj['action'] = ACTION_PUSH_TO_HEROKU; break; } } catch (err) { @@ -63,10 +63,10 @@ const exchangeCodeHeroku = async ({ code: string; }) => { let res: any; - let accessExpiresAt: any; + let accessExpiresAt = new Date(); try { res = await axios.post( - OAUTH_TOKEN_URL_HEROKU!, + INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: 'authorization_code', code: code, @@ -78,7 +78,6 @@ const exchangeCodeHeroku = async ({ accessExpiresAt.getSeconds() + res.data.expires_in ); } catch (err) { - console.error('integrationHerokuExchange'); Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Heroku'); diff --git a/backend/src/integrations/index.ts b/backend/src/integrations/index.ts index a97711cb57..3d8e8cc959 100644 --- a/backend/src/integrations/index.ts +++ b/backend/src/integrations/index.ts @@ -1,7 +1,11 @@ import { exchangeCode } from './exchange'; import { exchangeRefresh } from './refresh'; +import { getApps } from './apps'; +import { syncSecrets } from './sync'; export { exchangeCode, - exchangeRefresh + exchangeRefresh, + getApps, + syncSecrets } \ No newline at end of file diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 730c5a7267..b19a6a663e 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -5,7 +5,7 @@ import { OAUTH_CLIENT_SECRET_HEROKU } from '../config'; import { - OAUTH_TOKEN_URL_HEROKU + INTEGRATION_HEROKU_TOKEN_URL } from '../variables'; /** @@ -55,7 +55,7 @@ const exchangeRefreshHeroku = async ({ let accessToken; try { const res = await axios.post( - OAUTH_TOKEN_URL_HEROKU, + INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts new file mode 100644 index 0000000000..15a61ad669 --- /dev/null +++ b/backend/src/integrations/sync.ts @@ -0,0 +1,76 @@ +import axios from 'axios'; +import * as Sentry from '@sentry/node'; +import { INTEGRATION_HEROKU } from '../variables'; + +/** + * Sync/push [secrets] to [app] in integration named [integration] + * @param {Object} obj + * @param {Object} obj.integration - name of integration + * @param {Object} obj.app - app in integration + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for integration + */ +const syncSecrets = async ({ + integration, + app, + secrets, + accessToken +}: { + integration: string; + app: string; + secrets: any; + accessToken: string; +}) => { + try { + switch (integration) { + case INTEGRATION_HEROKU: + await syncSecretsHeroku({ + app, + secrets, + accessToken + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to integration'); + } +} + +/** + * Sync/push [secrets] to Heroku [app] + * @param {Object} obj + * @param {String} obj.app - app in integration + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + */ +const syncSecretsHeroku = async ({ + app, + secrets, + accessToken +}: { + app: string; + secrets: any; + accessToken: string; +}) => { + try { + await axios.patch( + `https://api.heroku.com/apps/${app}/config-vars`, + secrets, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: 'Bearer ' + accessToken + } + } + ); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); + } +} + +export { + syncSecrets +} \ No newline at end of file diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index dacc0f863f..fe653dbc06 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; -import { Integration, IntegrationAuth, Membership } from '../models'; -import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { Bot, Integration, IntegrationAuth, Membership } from '../models'; +import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; /** @@ -51,7 +51,9 @@ const requireIntegrationAuth = ({ } req.integration = integration; - req.accessToken = await getOAuthAccessToken({ integrationAuth }); + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); return next(); } catch (err) { diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 8fc5d329ec..0bf5526541 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/node'; import { Request, Response, NextFunction } from 'express'; import { IntegrationAuth } from '../models'; -import { getOAuthAccessToken } from '../helpers/integrationAuth'; +import { IntegrationService } from '../services'; import { validateMembership } from '../helpers/membership'; /** @@ -41,7 +41,10 @@ const requireIntegrationAuthorizationAuth = ({ }); req.integrationAuth = integrationAuth; - req.accessToken = await getOAuthAccessToken({ integrationAuth }); + req.accessToken = await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id.toString() + }); + return next(); } catch (err) { Sentry.setUser(null); diff --git a/backend/src/models/bot.ts b/backend/src/models/bot.ts index f492b31ba3..c7e5a9abec 100644 --- a/backend/src/models/bot.ts +++ b/backend/src/models/bot.ts @@ -24,12 +24,12 @@ const botSchema = new Schema( }, isActive: { type: Boolean, - required: true + required: true, + default: false }, publicKey: { type: String, - required: true, - select: false + required: true }, encryptedPrivateKey: { type: String, diff --git a/backend/src/models/botSequence.ts b/backend/src/models/botSequence.ts deleted file mode 100644 index 6112142849..0000000000 --- a/backend/src/models/botSequence.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Schema, model, Types } from 'mongoose'; - -export interface IBotSequence { - _id: Types.ObjectId; - bot: Types.ObjectId; - name: string; - event: string; - action: string; -} - -const botSequence = new Schema( - { - bot: { - type: Schema.Types.ObjectId, - ref: 'Bot', - required: true - }, - name: { - type: String, - required: true - }, - event: { - type: String, - required: true - }, - action: { - type: String, - required: true - } - }, - { - timestamps: true - } -); - -const BotSequence = model('BotSequence', botSequence); - -export default BotSequence; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index eef3e5fbb8..78c38060b9 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -1,7 +1,6 @@ import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey'; import Bot, { IBot } from './bot'; import BotKey, { IBotKey } from './botKey'; -import BotSequence, { IBotSequence } from './botSequence'; import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg'; import Integration, { IIntegration } from './integration'; import IntegrationAuth, { IIntegrationAuth } from './integrationAuth'; @@ -23,8 +22,6 @@ export { IBot, BotKey, IBotKey, - BotSequence, - IBotSequence, IncidentContactOrg, IIncidentContactOrg, Integration, diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts index e9f9380e9d..3189bec444 100644 --- a/backend/src/routes/bot.ts +++ b/backend/src/routes/bot.ts @@ -1,6 +1,6 @@ import express from 'express'; const router = express.Router(); -import { body } from 'express-validator'; +import { body, param } from 'express-validator'; import { requireAuth, requireBotAuth, @@ -11,14 +11,13 @@ import { botController } from '../controllers'; import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; router.get( - '/', + '/:workspaceId', requireAuth, requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], - acceptedStatuses: [COMPLETED, GRANTED], - location: 'body' + acceptedStatuses: [COMPLETED, GRANTED] }), - body('workspaceId').exists().trim().notEmpty(), + param('workspaceId').exists().trim().notEmpty(), validateRequest, botController.getBotByWorkspaceId ); diff --git a/backend/src/routes/integrationAuth.ts b/backend/src/routes/integrationAuth.ts index dc60c7643a..650221f82a 100644 --- a/backend/src/routes/integrationAuth.ts +++ b/backend/src/routes/integrationAuth.ts @@ -10,7 +10,7 @@ import { import { ADMIN, MEMBER, GRANTED } from '../variables'; import { integrationAuthController } from '../controllers'; -router.post( // semi-ok +router.post( '/oauth-token', requireAuth, requireWorkspaceAuth({ @@ -25,7 +25,7 @@ router.post( // semi-ok integrationAuthController.oAuthExchange ); -router.get( // not-ok +router.get( '/:integrationAuthId/apps', requireAuth, requireIntegrationAuthorizationAuth({ @@ -37,7 +37,7 @@ router.get( // not-ok integrationAuthController.getIntegrationAuthApps ); -router.delete( // not-ok +router.delete( '/:integrationAuthId', requireAuth, requireIntegrationAuthorizationAuth({ diff --git a/backend/src/services/ActionService.ts b/backend/src/services/ActionService.ts deleted file mode 100644 index 7d6de9dcb8..0000000000 --- a/backend/src/services/ActionService.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { IBot } from '../models'; -import { ACTION_PUSH_TO_HEROKU } from '../variables'; -import { actionPushToHeroku } from '../actions'; - -interface Event { - name: string; - workspaceId: string; - payload: any; -} - -/** - * Class to handle actions - */ -class ActionService { - /** - * @param {Object} obj - * @param {String} action - name of action to trigger - * @param {Event} event - * @param {String} obj.event.name - name of event - * @param {String} obj.event.workspaceId - id of workspace that event is part of - * @param {Object} obj.event.payload - payload of event (depends on event) - * @param bot - * @returns - */ - static async handleAction({ - action, - event, - bot - }: { - action: string; - event: Event; - bot: IBot; - }) { - try { - switch (action) { - case ACTION_PUSH_TO_HEROKU: - actionPushToHeroku({ - event, - bot - }); - return; - default: - return; - } - } catch (err) { - console.error('EventService err', err); - Sentry.setUser(null); - Sentry.captureException(err); - } - } -} - -export default ActionService; \ No newline at end of file diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts index a7bbd63bbd..792bd8e35a 100644 --- a/backend/src/services/BotService.ts +++ b/backend/src/services/BotService.ts @@ -1,19 +1,8 @@ -import { - Bot, - IBot, - BotKey, - IBotKey, - IUser, - Secret -} from '../models'; -import * as Sentry from '@sentry/node'; import { - decryptAsymmetric, - decryptSymmetric -} from '../utils/crypto'; -import { - ENCRYPTION_KEY -} from '../config'; + getSecretsHelper, + encryptSymmetricHelper, + decryptSymmetricHelper +} from '../helpers/bot'; /** * Class to handle bot actions @@ -21,87 +10,72 @@ import { class BotService { /** - * Return decrypted secrets using bot + * Return decrypted secrets for workspace with id [workspaceId] and + * environment [environmen] shared to bot. * @param {Object} obj * @param {String} obj.workspaceId - id of workspace of secrets * @param {String} obj.environment - environment for secrets + * @returns {Object} secretObj - object where keys are secret keys and values are secret values */ - static async decryptSecrets({ + static async getSecrets({ workspaceId, environment }: { workspaceId: string; environment: string; }) { - - let content: any = {}; - let bot; - let botKey; - try { - - // find bot - bot = await Bot.findOne({ - workspace: workspaceId, - isActive: true - }); - - if (!bot) throw new Error('Failed to find bot'); - - // find bot key - botKey = await BotKey.findOne({ - workspace: workspaceId - }).populate<{ sender: IUser }>('sender'); - - if (!botKey) throw new Error('Failed to find bot key'); - - // decrypt bot private key - const privateKey = decryptSymmetric({ - ciphertext: bot.encryptedPrivateKey, - iv: bot.iv, - tag: bot.tag, - key: ENCRYPTION_KEY - }); - - // decrypt workspace key - const key = decryptAsymmetric({ - ciphertext: botKey.encryptedKey, - nonce: botKey.nonce, - publicKey: botKey.sender.publicKey as string, - privateKey - }); - - // decrypt secrets - const secrets = await Secret.find({ - workspace: workspaceId, - environment - }); - - secrets.forEach(secret => { - // KEY, VALUE - const secretKey = decryptSymmetric({ - ciphertext: secret.secretKeyCiphertext, - iv: secret.secretKeyIV, - tag: secret.secretKeyTag, - key - }); - - const secretValue = decryptSymmetric({ - ciphertext: secret.secretValueCiphertext, - iv: secret.secretValueIV, - tag: secret.secretValueTag, - key - }); - - content[secretKey] = secretValue; - }); - - } catch (err) { - console.error('BotService'); - Sentry.setUser(null); - Sentry.captureException(err); - } - - return content; + return await getSecretsHelper({ + workspaceId, + environment + }); + } + + /** + * Return symmetrically encrypted [plaintext] using the + * bot's copy of the workspace key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.plaintext - plaintext to encrypt + */ + static async encryptSymmetric({ + workspaceId, + plaintext + }: { + workspaceId: string; + plaintext: string; + }) { + return await encryptSymmetricHelper({ + workspaceId, + plaintext + }); + } + + /** + * Return symmetrically decrypted [ciphertext] using the + * bot's copy of the workspace key for workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.workspaceId - id of workspace + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - iv + * @param {String} obj.tag - tag + */ + static async decryptSymmetric({ + workspaceId, + ciphertext, + iv, + tag + }: { + workspaceId: string; + ciphertext: string; + iv: string; + tag: string; + }) { + return await decryptSymmetricHelper({ + workspaceId, + ciphertext, + iv, + tag + }); } } diff --git a/backend/src/services/EventService.ts b/backend/src/services/EventService.ts index bfceaaa829..fcbac9ad01 100644 --- a/backend/src/services/EventService.ts +++ b/backend/src/services/EventService.ts @@ -1,6 +1,6 @@ -import { Bot, IBot, BotSequence } from '../models'; +import { Bot, IBot } from '../models'; import * as Sentry from '@sentry/node'; -import ActionService from './ActionService'; +import { handleEventHelper } from '../helpers/event'; interface Event { name: string; @@ -9,12 +9,11 @@ interface Event { } /** - * Class to handle events. TODO: elaborate DOCSTRING. + * Class to handle events. */ class EventService { /** - * Check if any bot sequences exist for event and forward - * bot sequence details to ActionService for execution + * Handle event [event] * @param {Object} obj * @param {Event} obj.event - an event * @param {String} obj.event.name - name of event @@ -22,53 +21,9 @@ class EventService { * @param {Object} obj.event.payload - payload of event (depends on event) */ static async handleEvent({ event }: { event: Event }): Promise { - let botSequences; - let bot: IBot | null; - try { - - console.log('EventService'); - const { workspaceId } = event; - - bot = await Bot.findOne({ - workspace: workspaceId, - isActive: true - }); - - console.log('A', bot); - // case: bot doesn't exist - if (!bot) { - return; - } - - botSequences = await BotSequence.find({ - bot: bot._id, - event: event.name - }); - - console.log('B', botSequences); - - // case: bot sequences don't exist - if (botSequences.length === 0) return; - - console.log('C'); - - return; - - // // execute event sequences - // botSequences.forEach(botSequence => { - // // sequence.actions - // ActionService.handleAction({ - // action: botSequence.action, - // event, - // bot: bot as IBot - // }); - // }); - - } catch (err) { - console.error('EventService err', err); - Sentry.setUser(null); - Sentry.captureException(err); - } + await handleEventHelper({ + event + }); } } diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index aa437e5c05..32f5f5a88b 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -1,16 +1,24 @@ import * as Sentry from '@sentry/node'; import { - Integration, - Bot, - BotSequence + Integration } from '../models'; +import { + handleOAuthExchangeHelper, + syncIntegrationsHelper, + getIntegrationAuthRefreshHelper, + getIntegrationAuthAccessHelper, + setIntegrationAuthRefreshHelper, + setIntegrationAuthAccessHelper, +} from '../helpers/integration'; import { exchangeCode } from '../integrations'; -import { processOAuthTokenRes2 } from '../helpers/integrationAuth'; import { ENV_DEV, EVENT_PUSH_SECRETS } from '../variables'; +// should sync stuff be here too? Probably. +// TODO: move bot functions to IntegrationService. + /** * Class to handle integrations */ @@ -36,57 +44,101 @@ class IntegrationService { integration: string; code: string; }) { - console.log('IntegrationService > handleO') - let action; - try { - - const bot = await Bot.find({ - workspace: workspaceId, - isActive: true - }); - - if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); - - // exchange code for access and refresh tokens - let res = await exchangeCode({ - integration, - code - }); - - const integrationAuth = await processOAuthTokenRes2({ - workspaceId, - integration, - accessToken: res.accessToken, - accessExpiresAt: res.accessExpiresAt, - refreshToken: res.refreshToken - }); + await handleOAuthExchangeHelper({ + workspaceId, + integration, + code + }); + } + + /** + * Sync/push environment variables in workspace with id [workspaceId] to + * all associated integrations + * @param {Object} obj + * @param {Object} obj.workspaceId - id of workspace + */ + static async syncIntegrations({ + workspaceId + }: { + workspaceId: string; + }) { + return await syncIntegrationsHelper({ + workspaceId + }); + } + + /** + * Return decrypted refresh token for integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} refreshToken - decrypted refresh token + */ + static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) { + return await getIntegrationAuthRefreshHelper({ + integrationAuthId + }); + } + + /** + * Return decrypted access token for integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} accessToken - decrypted access token + */ + static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) { + return await getIntegrationAuthAccessHelper({ + integrationAuthId + }); + } - await Integration.findOneAndUpdate( - { workspace: workspaceId, integration }, - { - workspace: workspaceId, - environment: ENV_DEV, - isActive: false, - app: null, - integration, - integrationAuth: integrationAuth._id - }, - { upsert: true, new: true } - ); - - // add bot sequence - await BotSequence.findOneAndUpdate({ - bot: bot._id, - name: integration + 'sequence', - event: EVENT_PUSH_SECRETS, - action - }); - } catch (err) { - console.error('IntegrationService error', err); - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to handle OAuth2 code-token exchange') - } + /** + * Encrypt refresh token [refreshToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.refreshToken - refresh token + * @returns {IntegrationAuth} integrationAuth - updated integration auth + */ + static async setIntegrationAuthRefresh({ + integrationAuthId, + refreshToken + }: { + integrationAuthId: string; + refreshToken: string; + }) { + return await setIntegrationAuthRefreshHelper({ + integrationAuthId, + refreshToken + }); + } + + /** + * Encrypt access token [accessToken] using the bot's copy + * of the workspace key for workspace belonging to integration auth + * with id [integrationAuthId] + * @param {Object} obj + * @param {String} obj.integrationAuthId - id of integration auth + * @param {String} obj.accessToken - access token + * @param {String} obj.accessExpiresAt - expiration date of access token + * @returns {IntegrationAuth} - updated integration auth + */ + static async setIntegrationAuthAccess({ + integrationAuthId, + accessToken, + accessExpiresAt + }: { + integrationAuthId: string; + accessToken: string; + accessExpiresAt: Date; + }) { + return await setIntegrationAuthAccessHelper({ + integrationAuthId, + accessToken, + accessExpiresAt + }); } } diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index c1cf420425..531033f30b 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -1,13 +1,11 @@ import postHogClient from './PostHogClient'; import BotService from './BotService'; import EventService from './EventService'; -import ActionService from './ActionService'; import IntegrationService from './IntegrationService'; export { postHogClient, BotService, EventService, - ActionService, IntegrationService } \ No newline at end of file diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts index 8172e7fc17..585dae51d2 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/utils/crypto.ts @@ -96,7 +96,7 @@ const decryptAsymmetric = ({ * Return symmetrically encrypted [plaintext] using [key]. * @param {Object} obj * @param {String} obj.plaintext - plaintext to encrypt - * @param {String} obj.key - 16-byte hex key + * @param {String} obj.key - hex key */ const encryptSymmetric = ({ plaintext, @@ -129,7 +129,7 @@ const encryptSymmetric = ({ * @param {String} obj.ciphertext - ciphertext to decrypt * @param {String} obj.iv - iv * @param {String} obj.tag - tag - * @param {String} obj.key - 32-byte hex key + * @param {String} obj.key - hex key * */ const decryptSymmetric = ({ diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 135a455890..9f8bcbd9c0 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -10,7 +10,8 @@ import { INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, - OAUTH_TOKEN_URL_HEROKU + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_HEROKU_APPS_URL } from './integration'; import { OWNER, @@ -58,7 +59,8 @@ export { INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, - OAUTH_TOKEN_URL_HEROKU, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_HEROKU_APPS_URL, EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS, ACTION_PUSH_TO_HEROKU diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index a63f2299f8..6e4fe45b5c 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -7,12 +7,16 @@ const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]); const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints -const OAUTH_TOKEN_URL_HEROKU = 'https://id.heroku.com/oauth/token'; +const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; + +// integration apps endpoints +const INTEGRATION_HEROKU_APPS_URL = 'https://api.heroku.com/apps'; export { INTEGRATION_HEROKU, INTEGRATION_NETLIFY, INTEGRATION_SET, INTEGRATION_OAUTH2, - OAUTH_TOKEN_URL_HEROKU + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_HEROKU_APPS_URL } \ No newline at end of file diff --git a/frontend/components/utilities/secrets/getSecretsForProject.js b/frontend/components/utilities/secrets/getSecretsForProject.js index 7bdac922ff..2e2a4413c3 100644 --- a/frontend/components/utilities/secrets/getSecretsForProject.js +++ b/frontend/components/utilities/secrets/getSecretsForProject.js @@ -42,7 +42,7 @@ const getSecretsForProject = async ({ publicKey: file.key.sender.publicKey, privateKey: PRIVATE_KEY, }); - + file.secrets.map((secretPair) => { // decrypt .env file with symmetric key const plainTextKey = decryptSymmetric({ diff --git a/frontend/pages/api/bot/getBot.js b/frontend/pages/api/bot/getBot.js new file mode 100644 index 0000000000..465f2d189d --- /dev/null +++ b/frontend/pages/api/bot/getBot.js @@ -0,0 +1,27 @@ +import SecurityClient from "~/utilities/SecurityClient.js"; + +/** + * This function fetches the bot for a project + * @param {Object} obj + * @param {String} obj.workspaceId + * @returns + */ +const getBot = async ({ workspaceId }) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + workspaceId, + { + method: "GET", + headers: { + "Content-Type": "application/json", + } + } + ).then(async (res) => { + if (res.status == 200) { + return await res.json(); + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default getBot; \ No newline at end of file diff --git a/frontend/pages/api/bot/setBotActiveStatus.js b/frontend/pages/api/bot/setBotActiveStatus.js new file mode 100644 index 0000000000..8e1b51f24e --- /dev/null +++ b/frontend/pages/api/bot/setBotActiveStatus.js @@ -0,0 +1,31 @@ +import SecurityClient from "~/utilities/SecurityClient.js"; + +/** + * This function fetches the bot for a project + * @param {Object} obj + * @param {String} obj.workspaceId + * @returns + */ +const setBotActiveStatus = async ({ botId, isActive, botKey }) => { + return SecurityClient.fetchCall( + "/api/v1/bot/" + botId + "/active", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + isActive, + botKey + }) + } + ).then(async (res) => { + if (res.status == 200) { + return await res.json(); + } else { + console.log("Failed to get bot for project"); + } + }); +}; + +export default setBotActiveStatus; \ No newline at end of file diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 898882e311..a5603985d2 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -29,6 +29,14 @@ import getIntegrations from "../api/integrations/GetIntegrations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; import startIntegration from "../api/integrations/StartIntegration"; +import getBot from "../api/bot/getBot"; +import setBotActiveStatus from "../api/bot/setBotActiveStatus"; +import getLatestFileKey from "../api/workspace/getLatestFileKey"; + +const { + decryptAssymmetric, + encryptAssymmetric +} = require('../../components/utilities/cryptography/crypto'); const crypto = require("crypto"); @@ -169,6 +177,7 @@ export default function Integrations() { const [authorizations, setAuthorizations] = useState(); const router = useRouter(); const [csrfToken, setCsrfToken] = useState(""); + const [bot, setBot] = useState(null); useEffect(async () => { const tempCSRFToken = crypto.randomBytes(16).toString("hex"); @@ -184,6 +193,12 @@ export default function Integrations() { workspaceId: router.query.id, }); setProjectIntegrations(projectIntegrations); + + const bot = await getBot({ + workspaceId: router.query.id + }); + + setBot(bot.bot); try { const integrationsData = await getIntegrations(); @@ -193,6 +208,50 @@ export default function Integrations() { } }, []); + /** + * Toggle activate/deactivate bot + */ + const handleBotActivate = async () => { + const k = await getLatestFileKey({ workspaceId: router.query.id }); + try { + if (bot) { + let botKey; + if (!bot.isActive) { + // case: bot is active -> deactivate + + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY'); + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: k.latestKey.encryptedKey, + nonce: k.latestKey.nonce, + publicKey: k.latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: bot.publicKey, + privateKey: PRIVATE_KEY + }); + + botKey = { + encryptedKey: ciphertext, + nonce + } + } + + // case: bot is not active + const bot2 = await setBotActiveStatus({ + botId: bot._id, + isActive: bot.isActive ? false : true, + botKey + }); + setBot(bot2.bot); + } + } catch (err) { + console.error(err); + } + } + return integrations ? (
@@ -212,6 +271,9 @@ export default function Integrations() {

Current Project Integrations

+

Manage your integrations of Infisical with third-party services.