Complete v1 loop for bot-based integrations

This commit is contained in:
Tuan Dang
2022-12-08 23:22:44 -05:00
parent 46fe724012
commit 1757f0d690
33 changed files with 1085 additions and 638 deletions
-1
View File
@@ -47,7 +47,6 @@ SMTP_PASSWORD=
# Integration # Integration
# Optional only if integration is used # Optional only if integration is used
OAUTH_CLIENT_SECRET_HEROKU= OAUTH_CLIENT_SECRET_HEROKU=
OAUTH_TOKEN_URL_HEROKU=
# Sentry (optional) for monitoring errors # Sentry (optional) for monitoring errors
SENTRY_DSN= SENTRY_DSN=
+15 -4
View File
@@ -18,7 +18,7 @@ interface BotKey {
export const getBotByWorkspaceId = async (req: Request, res: Response) => { export const getBotByWorkspaceId = async (req: Request, res: Response) => {
let bot; let bot;
try { try {
const { workspaceId } = req.body; const { workspaceId } = req.params;
bot = await Bot.findOne({ bot = await Bot.findOne({
workspace: workspaceId workspace: workspaceId
@@ -58,13 +58,24 @@ export const setBotActiveState = async (req: Request, res: Response) => {
if (isActive) { if (isActive) {
// bot state set to active -> share workspace key with bot // 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, encryptedKey: botKey.encryptedKey,
nonce: botKey.nonce, nonce: botKey.nonce,
sender: req.user._id, sender: req.user._id,
receiver: req.bot._id, bot: req.bot._id,
workspace: req.bot.workspace workspace: req.bot.workspace
}).save(); }, {
upsert: true,
new: true
});
} else { } else {
// case: bot state set to inactive -> delete bot's workspace key // case: bot state set to inactive -> delete bot's workspace key
await BotKey.deleteOne({ await BotKey.deleteOne({
@@ -3,15 +3,13 @@ import * as Sentry from '@sentry/node';
import axios from 'axios'; import axios from 'axios';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { IntegrationAuth, Integration } from '../models'; import { IntegrationAuth, Integration } from '../models';
import { processOAuthTokenRes } from '../helpers/integrationAuth';
import { INTEGRATION_SET, ENV_DEV } from '../variables'; import { INTEGRATION_SET, ENV_DEV } from '../variables';
import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config'; import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config';
import { IntegrationService } from '../services'; import { IntegrationService } from '../services';
import { getApps } from '../integrations';
/** /**
* Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId] * 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 req
* @param res * @param res
* @returns * @returns
@@ -21,8 +19,6 @@ export const oAuthExchange = async (
res: Response res: Response
) => { ) => {
try { try {
// let clientSecret;
const { workspaceId, code, integration } = req.body; const { workspaceId, code, integration } = req.body;
if (!INTEGRATION_SET.has(integration)) if (!INTEGRATION_SET.has(integration))
@@ -33,42 +29,6 @@ export const oAuthExchange = async (
integration, integration,
code 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) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); 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 req
* @param res * @param res
* @returns * @returns
*/ */
export const getIntegrationAuthApps = async (req: Request, res: Response) => { export const getIntegrationAuthApps = async (req: Request, res: Response) => {
// TODO: unfinished - make compatible with other integration types
let apps; let apps;
try { try {
const res = await axios.get('https://api.heroku.com/apps', { apps = await getApps({
headers: { integration: req.integrationAuth.integration,
Accept: 'application/vnd.heroku+json; version=3', accessToken: req.accessToken
Authorization: 'Bearer ' + req.accessToken
}
}); });
} catch (err) {
apps = res.data.map((a: any) => ({ Sentry.setUser(null);
name: a.name Sentry.captureException(err);
})); return res.status(400).send({
} catch (err) {} message: 'Failed to get integration authorization applications'
});
}
return res.status(200).send({ return res.status(200).send({
apps apps
@@ -49,7 +49,6 @@ export const getIntegrations = async (req: Request, res: Response) => {
}); });
}; };
// TODO: deprecate
/** /**
* Sync secrets [secrets] to integration with id [integrationId] * Sync secrets [secrets] to integration with id [integrationId]
* @param req * @param req
@@ -57,7 +56,10 @@ export const getIntegrations = async (req: Request, res: Response) => {
* @returns * @returns
*/ */
export const syncIntegration = async (req: Request, res: Response) => { 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 { try {
const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body; const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body;
const symmetricKey = decryptAsymmetric({ const symmetricKey = decryptAsymmetric({
@@ -106,6 +108,7 @@ export const syncIntegration = async (req: Request, res: Response) => {
*/ */
export const modifyIntegration = async (req: Request, res: Response) => { export const modifyIntegration = async (req: Request, res: Response) => {
let integration; let integration;
try { try {
const { update } = req.body; const { update } = req.body;
+183 -3
View File
@@ -1,9 +1,20 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { import {
Bot Bot,
BotKey,
Secret,
ISecret,
IUser
} from '../models'; } 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 { ENCRYPTION_KEY } from '../config';
import { SECRET_SHARED } from '../variables';
/** /**
* Create an inactive bot with name [name] for workspace with id [workspaceId] * Create an inactive bot with name [name] for workspace with id [workspaceId]
@@ -44,6 +55,175 @@ const createBot = async ({
return bot; 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 { export {
createBot createBot,
getSecretsHelper,
encryptSymmetricHelper,
decryptSymmetricHelper
} }
+51
View File
@@ -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
}
+324
View File
@@ -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
}
-250
View File
@@ -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<IIntegrationAuth> => {
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 };
+4 -4
View File
@@ -30,10 +30,10 @@ const createWorkspace = async ({
organization: organizationId organization: organizationId
}).save(); }).save();
// const bot = await createBot({ const bot = await createBot({
// name: 'Infisical Bot', name: 'Infisical Bot',
// workspaceId: workspace._id.toString() workspaceId: workspace._id.toString()
// }); });
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
+77
View File
@@ -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
}
+5 -6
View File
@@ -2,11 +2,11 @@ import axios from 'axios';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { import {
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_HEROKU_TOKEN_URL,
ACTION_PUSH_TO_HEROKU ACTION_PUSH_TO_HEROKU
} from '../variables'; } from '../variables';
import { import {
OAUTH_CLIENT_SECRET_HEROKU, OAUTH_CLIENT_SECRET_HEROKU
OAUTH_TOKEN_URL_HEROKU
} from '../config'; } from '../config';
/** /**
@@ -29,13 +29,13 @@ const exchangeCode = async ({
code: string; code: string;
}) => { }) => {
let obj = {} as any; let obj = {} as any;
try { try {
switch (integration) { switch (integration) {
case INTEGRATION_HEROKU: case INTEGRATION_HEROKU:
obj = await exchangeCodeHeroku({ obj = await exchangeCodeHeroku({
code code
}); });
obj['action'] = ACTION_PUSH_TO_HEROKU;
break; break;
} }
} catch (err) { } catch (err) {
@@ -63,10 +63,10 @@ const exchangeCodeHeroku = async ({
code: string; code: string;
}) => { }) => {
let res: any; let res: any;
let accessExpiresAt: any; let accessExpiresAt = new Date();
try { try {
res = await axios.post( res = await axios.post(
OAUTH_TOKEN_URL_HEROKU!, INTEGRATION_HEROKU_TOKEN_URL,
new URLSearchParams({ new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code: code, code: code,
@@ -78,7 +78,6 @@ const exchangeCodeHeroku = async ({
accessExpiresAt.getSeconds() + res.data.expires_in accessExpiresAt.getSeconds() + res.data.expires_in
); );
} catch (err) { } catch (err) {
console.error('integrationHerokuExchange');
Sentry.setUser(null); Sentry.setUser(null);
Sentry.captureException(err); Sentry.captureException(err);
throw new Error('Failed OAuth2 code-token exchange with Heroku'); throw new Error('Failed OAuth2 code-token exchange with Heroku');
+5 -1
View File
@@ -1,7 +1,11 @@
import { exchangeCode } from './exchange'; import { exchangeCode } from './exchange';
import { exchangeRefresh } from './refresh'; import { exchangeRefresh } from './refresh';
import { getApps } from './apps';
import { syncSecrets } from './sync';
export { export {
exchangeCode, exchangeCode,
exchangeRefresh exchangeRefresh,
getApps,
syncSecrets
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import {
OAUTH_CLIENT_SECRET_HEROKU OAUTH_CLIENT_SECRET_HEROKU
} from '../config'; } from '../config';
import { import {
OAUTH_TOKEN_URL_HEROKU INTEGRATION_HEROKU_TOKEN_URL
} from '../variables'; } from '../variables';
/** /**
@@ -55,7 +55,7 @@ const exchangeRefreshHeroku = async ({
let accessToken; let accessToken;
try { try {
const res = await axios.post( const res = await axios.post(
OAUTH_TOKEN_URL_HEROKU, INTEGRATION_HEROKU_TOKEN_URL,
new URLSearchParams({ new URLSearchParams({
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: refreshToken, refresh_token: refreshToken,
+76
View File
@@ -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
}
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { Integration, IntegrationAuth, Membership } from '../models'; import { Bot, Integration, IntegrationAuth, Membership } from '../models';
import { getOAuthAccessToken } from '../helpers/integrationAuth'; import { IntegrationService } from '../services';
import { validateMembership } from '../helpers/membership'; import { validateMembership } from '../helpers/membership';
/** /**
@@ -51,7 +51,9 @@ const requireIntegrationAuth = ({
} }
req.integration = integration; req.integration = integration;
req.accessToken = await getOAuthAccessToken({ integrationAuth }); req.accessToken = await IntegrationService.getIntegrationAuthAccess({
integrationAuthId: integrationAuth._id.toString()
});
return next(); return next();
} catch (err) { } catch (err) {
@@ -1,7 +1,7 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { IntegrationAuth } from '../models'; import { IntegrationAuth } from '../models';
import { getOAuthAccessToken } from '../helpers/integrationAuth'; import { IntegrationService } from '../services';
import { validateMembership } from '../helpers/membership'; import { validateMembership } from '../helpers/membership';
/** /**
@@ -41,7 +41,10 @@ const requireIntegrationAuthorizationAuth = ({
}); });
req.integrationAuth = integrationAuth; req.integrationAuth = integrationAuth;
req.accessToken = await getOAuthAccessToken({ integrationAuth }); req.accessToken = await IntegrationService.getIntegrationAuthAccess({
integrationAuthId: integrationAuth._id.toString()
});
return next(); return next();
} catch (err) { } catch (err) {
Sentry.setUser(null); Sentry.setUser(null);
+3 -3
View File
@@ -24,12 +24,12 @@ const botSchema = new Schema<IBot>(
}, },
isActive: { isActive: {
type: Boolean, type: Boolean,
required: true required: true,
default: false
}, },
publicKey: { publicKey: {
type: String, type: String,
required: true, required: true
select: false
}, },
encryptedPrivateKey: { encryptedPrivateKey: {
type: String, type: String,
-38
View File
@@ -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<IBotSequence>(
{
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<IBotSequence>('BotSequence', botSequence);
export default BotSequence;
-3
View File
@@ -1,7 +1,6 @@
import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey'; import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey';
import Bot, { IBot } from './bot'; import Bot, { IBot } from './bot';
import BotKey, { IBotKey } from './botKey'; import BotKey, { IBotKey } from './botKey';
import BotSequence, { IBotSequence } from './botSequence';
import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg'; import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg';
import Integration, { IIntegration } from './integration'; import Integration, { IIntegration } from './integration';
import IntegrationAuth, { IIntegrationAuth } from './integrationAuth'; import IntegrationAuth, { IIntegrationAuth } from './integrationAuth';
@@ -23,8 +22,6 @@ export {
IBot, IBot,
BotKey, BotKey,
IBotKey, IBotKey,
BotSequence,
IBotSequence,
IncidentContactOrg, IncidentContactOrg,
IIncidentContactOrg, IIncidentContactOrg,
Integration, Integration,
+4 -5
View File
@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
const router = express.Router(); const router = express.Router();
import { body } from 'express-validator'; import { body, param } from 'express-validator';
import { import {
requireAuth, requireAuth,
requireBotAuth, requireBotAuth,
@@ -11,14 +11,13 @@ import { botController } from '../controllers';
import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables'; import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables';
router.get( router.get(
'/', '/:workspaceId',
requireAuth, requireAuth,
requireWorkspaceAuth({ requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
acceptedStatuses: [COMPLETED, GRANTED], acceptedStatuses: [COMPLETED, GRANTED]
location: 'body'
}), }),
body('workspaceId').exists().trim().notEmpty(), param('workspaceId').exists().trim().notEmpty(),
validateRequest, validateRequest,
botController.getBotByWorkspaceId botController.getBotByWorkspaceId
); );
+3 -3
View File
@@ -10,7 +10,7 @@ import {
import { ADMIN, MEMBER, GRANTED } from '../variables'; import { ADMIN, MEMBER, GRANTED } from '../variables';
import { integrationAuthController } from '../controllers'; import { integrationAuthController } from '../controllers';
router.post( // semi-ok router.post(
'/oauth-token', '/oauth-token',
requireAuth, requireAuth,
requireWorkspaceAuth({ requireWorkspaceAuth({
@@ -25,7 +25,7 @@ router.post( // semi-ok
integrationAuthController.oAuthExchange integrationAuthController.oAuthExchange
); );
router.get( // not-ok router.get(
'/:integrationAuthId/apps', '/:integrationAuthId/apps',
requireAuth, requireAuth,
requireIntegrationAuthorizationAuth({ requireIntegrationAuthorizationAuth({
@@ -37,7 +37,7 @@ router.get( // not-ok
integrationAuthController.getIntegrationAuthApps integrationAuthController.getIntegrationAuthApps
); );
router.delete( // not-ok router.delete(
'/:integrationAuthId', '/:integrationAuthId',
requireAuth, requireAuth,
requireIntegrationAuthorizationAuth({ requireIntegrationAuthorizationAuth({
-54
View File
@@ -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;
+60 -86
View File
@@ -1,19 +1,8 @@
import {
Bot,
IBot,
BotKey,
IBotKey,
IUser,
Secret
} from '../models';
import * as Sentry from '@sentry/node';
import { import {
decryptAsymmetric, getSecretsHelper,
decryptSymmetric encryptSymmetricHelper,
} from '../utils/crypto'; decryptSymmetricHelper
import { } from '../helpers/bot';
ENCRYPTION_KEY
} from '../config';
/** /**
* Class to handle bot actions * Class to handle bot actions
@@ -21,87 +10,72 @@ import {
class BotService { 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 {Object} obj
* @param {String} obj.workspaceId - id of workspace of secrets * @param {String} obj.workspaceId - id of workspace of secrets
* @param {String} obj.environment - environment for 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, workspaceId,
environment environment
}: { }: {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
}) { }) {
return await getSecretsHelper({
let content: any = {}; workspaceId,
let bot; environment
let botKey; });
try { }
// find bot /**
bot = await Bot.findOne({ * Return symmetrically encrypted [plaintext] using the
workspace: workspaceId, * bot's copy of the workspace key for workspace with id [workspaceId]
isActive: true * @param {Object} obj
}); * @param {String} obj.workspaceId - id of workspace
* @param {String} obj.plaintext - plaintext to encrypt
if (!bot) throw new Error('Failed to find bot'); */
static async encryptSymmetric({
// find bot key workspaceId,
botKey = await BotKey.findOne({ plaintext
workspace: workspaceId }: {
}).populate<{ sender: IUser }>('sender'); workspaceId: string;
plaintext: string;
if (!botKey) throw new Error('Failed to find bot key'); }) {
return await encryptSymmetricHelper({
// decrypt bot private key workspaceId,
const privateKey = decryptSymmetric({ plaintext
ciphertext: bot.encryptedPrivateKey, });
iv: bot.iv, }
tag: bot.tag,
key: ENCRYPTION_KEY /**
}); * Return symmetrically decrypted [ciphertext] using the
* bot's copy of the workspace key for workspace with id [workspaceId]
// decrypt workspace key * @param {Object} obj
const key = decryptAsymmetric({ * @param {String} obj.workspaceId - id of workspace
ciphertext: botKey.encryptedKey, * @param {String} obj.ciphertext - ciphertext to decrypt
nonce: botKey.nonce, * @param {String} obj.iv - iv
publicKey: botKey.sender.publicKey as string, * @param {String} obj.tag - tag
privateKey */
}); static async decryptSymmetric({
workspaceId,
// decrypt secrets ciphertext,
const secrets = await Secret.find({ iv,
workspace: workspaceId, tag
environment }: {
}); workspaceId: string;
ciphertext: string;
secrets.forEach(secret => { iv: string;
// KEY, VALUE tag: string;
const secretKey = decryptSymmetric({ }) {
ciphertext: secret.secretKeyCiphertext, return await decryptSymmetricHelper({
iv: secret.secretKeyIV, workspaceId,
tag: secret.secretKeyTag, ciphertext,
key iv,
}); tag
});
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;
} }
} }
+7 -52
View File
@@ -1,6 +1,6 @@
import { Bot, IBot, BotSequence } from '../models'; import { Bot, IBot } from '../models';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import ActionService from './ActionService'; import { handleEventHelper } from '../helpers/event';
interface Event { interface Event {
name: string; name: string;
@@ -9,12 +9,11 @@ interface Event {
} }
/** /**
* Class to handle events. TODO: elaborate DOCSTRING. * Class to handle events.
*/ */
class EventService { class EventService {
/** /**
* Check if any bot sequences exist for event and forward * Handle event [event]
* bot sequence details to ActionService for execution
* @param {Object} obj * @param {Object} obj
* @param {Event} obj.event - an event * @param {Event} obj.event - an event
* @param {String} obj.event.name - name of 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) * @param {Object} obj.event.payload - payload of event (depends on event)
*/ */
static async handleEvent({ event }: { event: Event }): Promise<void> { static async handleEvent({ event }: { event: Event }): Promise<void> {
let botSequences; await handleEventHelper({
let bot: IBot | null; event
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);
}
} }
} }
+106 -54
View File
@@ -1,16 +1,24 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { import {
Integration, Integration
Bot,
BotSequence
} from '../models'; } from '../models';
import {
handleOAuthExchangeHelper,
syncIntegrationsHelper,
getIntegrationAuthRefreshHelper,
getIntegrationAuthAccessHelper,
setIntegrationAuthRefreshHelper,
setIntegrationAuthAccessHelper,
} from '../helpers/integration';
import { exchangeCode } from '../integrations'; import { exchangeCode } from '../integrations';
import { processOAuthTokenRes2 } from '../helpers/integrationAuth';
import { import {
ENV_DEV, ENV_DEV,
EVENT_PUSH_SECRETS EVENT_PUSH_SECRETS
} from '../variables'; } from '../variables';
// should sync stuff be here too? Probably.
// TODO: move bot functions to IntegrationService.
/** /**
* Class to handle integrations * Class to handle integrations
*/ */
@@ -36,57 +44,101 @@ class IntegrationService {
integration: string; integration: string;
code: string; code: string;
}) { }) {
console.log('IntegrationService > handleO') await handleOAuthExchangeHelper({
let action; workspaceId,
try { integration,
code
const bot = await Bot.find({ });
workspace: workspaceId, }
isActive: true
}); /**
* Sync/push environment variables in workspace with id [workspaceId] to
if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange'); * all associated integrations
* @param {Object} obj
// exchange code for access and refresh tokens * @param {Object} obj.workspaceId - id of workspace
let res = await exchangeCode({ */
integration, static async syncIntegrations({
code workspaceId
}); }: {
workspaceId: string;
const integrationAuth = await processOAuthTokenRes2({ }) {
workspaceId, return await syncIntegrationsHelper({
integration, workspaceId
accessToken: res.accessToken, });
accessExpiresAt: res.accessExpiresAt, }
refreshToken: res.refreshToken
}); /**
* 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 }, * Encrypt refresh token [refreshToken] using the bot's copy
{ * of the workspace key for workspace belonging to integration auth
workspace: workspaceId, * with id [integrationAuthId]
environment: ENV_DEV, * @param {Object} obj
isActive: false, * @param {String} obj.integrationAuthId - id of integration auth
app: null, * @param {String} obj.refreshToken - refresh token
integration, * @returns {IntegrationAuth} integrationAuth - updated integration auth
integrationAuth: integrationAuth._id */
}, static async setIntegrationAuthRefresh({
{ upsert: true, new: true } integrationAuthId,
); refreshToken
}: {
// add bot sequence integrationAuthId: string;
await BotSequence.findOneAndUpdate({ refreshToken: string;
bot: bot._id, }) {
name: integration + 'sequence', return await setIntegrationAuthRefreshHelper({
event: EVENT_PUSH_SECRETS, integrationAuthId,
action refreshToken
}); });
} catch (err) { }
console.error('IntegrationService error', err);
Sentry.setUser(null); /**
Sentry.captureException(err); * Encrypt access token [accessToken] using the bot's copy
throw new Error('Failed to handle OAuth2 code-token exchange') * 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
});
} }
} }
-2
View File
@@ -1,13 +1,11 @@
import postHogClient from './PostHogClient'; import postHogClient from './PostHogClient';
import BotService from './BotService'; import BotService from './BotService';
import EventService from './EventService'; import EventService from './EventService';
import ActionService from './ActionService';
import IntegrationService from './IntegrationService'; import IntegrationService from './IntegrationService';
export { export {
postHogClient, postHogClient,
BotService, BotService,
EventService, EventService,
ActionService,
IntegrationService IntegrationService
} }
+2 -2
View File
@@ -96,7 +96,7 @@ const decryptAsymmetric = ({
* Return symmetrically encrypted [plaintext] using [key]. * Return symmetrically encrypted [plaintext] using [key].
* @param {Object} obj * @param {Object} obj
* @param {String} obj.plaintext - plaintext to encrypt * @param {String} obj.plaintext - plaintext to encrypt
* @param {String} obj.key - 16-byte hex key * @param {String} obj.key - hex key
*/ */
const encryptSymmetric = ({ const encryptSymmetric = ({
plaintext, plaintext,
@@ -129,7 +129,7 @@ const encryptSymmetric = ({
* @param {String} obj.ciphertext - ciphertext to decrypt * @param {String} obj.ciphertext - ciphertext to decrypt
* @param {String} obj.iv - iv * @param {String} obj.iv - iv
* @param {String} obj.tag - tag * @param {String} obj.tag - tag
* @param {String} obj.key - 32-byte hex key * @param {String} obj.key - hex key
* *
*/ */
const decryptSymmetric = ({ const decryptSymmetric = ({
+4 -2
View File
@@ -10,7 +10,8 @@ import {
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
OAUTH_TOKEN_URL_HEROKU INTEGRATION_HEROKU_TOKEN_URL,
INTEGRATION_HEROKU_APPS_URL
} from './integration'; } from './integration';
import { import {
OWNER, OWNER,
@@ -58,7 +59,8 @@ export {
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
OAUTH_TOKEN_URL_HEROKU, INTEGRATION_HEROKU_TOKEN_URL,
INTEGRATION_HEROKU_APPS_URL,
EVENT_PUSH_SECRETS, EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS, EVENT_PULL_SECRETS,
ACTION_PUSH_TO_HEROKU ACTION_PUSH_TO_HEROKU
+6 -2
View File
@@ -7,12 +7,16 @@ const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]);
const INTEGRATION_OAUTH2 = 'oauth2'; const INTEGRATION_OAUTH2 = 'oauth2';
// integration oauth endpoints // 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 { export {
INTEGRATION_HEROKU, INTEGRATION_HEROKU,
INTEGRATION_NETLIFY, INTEGRATION_NETLIFY,
INTEGRATION_SET, INTEGRATION_SET,
INTEGRATION_OAUTH2, INTEGRATION_OAUTH2,
OAUTH_TOKEN_URL_HEROKU INTEGRATION_HEROKU_TOKEN_URL,
INTEGRATION_HEROKU_APPS_URL
} }
@@ -42,7 +42,7 @@ const getSecretsForProject = async ({
publicKey: file.key.sender.publicKey, publicKey: file.key.sender.publicKey,
privateKey: PRIVATE_KEY, privateKey: PRIVATE_KEY,
}); });
file.secrets.map((secretPair) => { file.secrets.map((secretPair) => {
// decrypt .env file with symmetric key // decrypt .env file with symmetric key
const plainTextKey = decryptSymmetric({ const plainTextKey = decryptSymmetric({
+27
View File
@@ -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;
@@ -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;
+62
View File
@@ -29,6 +29,14 @@ import getIntegrations from "../api/integrations/GetIntegrations";
import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations"; import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations";
import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations"; import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations";
import startIntegration from "../api/integrations/StartIntegration"; 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"); const crypto = require("crypto");
@@ -169,6 +177,7 @@ export default function Integrations() {
const [authorizations, setAuthorizations] = useState(); const [authorizations, setAuthorizations] = useState();
const router = useRouter(); const router = useRouter();
const [csrfToken, setCsrfToken] = useState(""); const [csrfToken, setCsrfToken] = useState("");
const [bot, setBot] = useState(null);
useEffect(async () => { useEffect(async () => {
const tempCSRFToken = crypto.randomBytes(16).toString("hex"); const tempCSRFToken = crypto.randomBytes(16).toString("hex");
@@ -184,6 +193,12 @@ export default function Integrations() {
workspaceId: router.query.id, workspaceId: router.query.id,
}); });
setProjectIntegrations(projectIntegrations); setProjectIntegrations(projectIntegrations);
const bot = await getBot({
workspaceId: router.query.id
});
setBot(bot.bot);
try { try {
const integrationsData = await getIntegrations(); 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 ? ( return integrations ? (
<div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white"> <div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white">
<Head> <Head>
@@ -212,6 +271,9 @@ export default function Integrations() {
<div className="flex flex-row justify-start items-center text-3xl"> <div className="flex flex-row justify-start items-center text-3xl">
<p className="font-semibold mr-4">Current Project Integrations</p> <p className="font-semibold mr-4">Current Project Integrations</p>
</div> </div>
<button onClick={() => handleBotActivate()}>
{(bot && bot?.isActive) ? 'Deactivate bot' : 'Activate bot'}
</button>
<p className="mr-4 text-base text-gray-400"> <p className="mr-4 text-base text-gray-400">
Manage your integrations of Infisical with third-party services. Manage your integrations of Infisical with third-party services.
</p> </p>