diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 2ecd1af8cc..ad1acb1a6b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -13,10 +13,13 @@ const MONGO_URL = process.env.MONGO_URL!; const NODE_ENV = process.env.NODE_ENV! || 'production'; const VERBOSE_ERROR_OUTPUT = process.env.VERBOSE_ERROR_OUTPUT! === 'true' && true; const LOKI_HOST = process.env.LOKI_HOST || undefined; +const CLIENT_ID_AZURE = process.env.CLIENT_ID_AZURE!; +const TENANT_ID_AZURE = process.env.TENANT_ID_AZURE!; const CLIENT_ID_HEROKU = process.env.CLIENT_ID_HEROKU!; const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!; const CLIENT_ID_NETLIFY = process.env.CLIENT_ID_NETLIFY!; const CLIENT_ID_GITHUB = process.env.CLIENT_ID_GITHUB!; +const CLIENT_SECRET_AZURE = process.env.CLIENT_SECRET_AZURE!; const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!; @@ -60,10 +63,13 @@ export { NODE_ENV, VERBOSE_ERROR_OUTPUT, LOKI_HOST, + CLIENT_ID_AZURE, + TENANT_ID_AZURE, CLIENT_ID_HEROKU, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, CLIENT_ID_GITHUB, + CLIENT_SECRET_AZURE, CLIENT_SECRET_HEROKU, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b030b79d6f..cedc7e3453 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -31,7 +31,7 @@ export const oAuthExchange = async ( ) => { try { const { workspaceId, code, integration } = req.body; - + if (!INTEGRATION_SET.has(integration)) throw new Error('Failed to validate integration'); @@ -40,12 +40,14 @@ export const oAuthExchange = async ( throw new Error("Failed to get environments") } - await IntegrationService.handleOAuthExchange({ + const integrationDetails = await IntegrationService.handleOAuthExchange({ workspaceId, integration, code, environment: environments[0].slug, }); + + return res.status(200).send(integrationDetails); } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); @@ -53,10 +55,6 @@ export const oAuthExchange = async ( message: 'Failed to get OAuth2 code-token exchange' }); } - - return res.status(200).send({ - message: 'Successfully enabled integration authorization' - }); }; /** diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 4e66ce0aad..2b52f97252 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -16,6 +16,9 @@ import { eventPushSecrets } from '../../events'; * @returns */ export const createIntegration = async (req: Request, res: Response) => { + + // TODO: make this more versatile + let integration; try { // initialize new integration after saving integration access token diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 1618e5e677..595dbbb6f4 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -44,6 +44,7 @@ const handleOAuthExchangeHelper = async ({ }) => { let action; let integrationAuth; + let newIntegration; try { const bot = await Bot.findOne({ workspace: workspaceId, @@ -100,7 +101,7 @@ const handleOAuthExchangeHelper = async ({ } // initialize new integration after exchange - await new Integration({ + newIntegration = await new Integration({ workspace: workspaceId, isActive: false, app: null, @@ -113,6 +114,11 @@ const handleOAuthExchangeHelper = async ({ Sentry.captureException(err); throw new Error('Failed to handle OAuth2 code-token exchange') } + + return ({ + integrationAuth, + integration: newIntegration + }); } /** * Sync/push environment variables in workspace with id [workspaceId] to diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index d1252954ee..50f146be57 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/node'; import { Octokit } from '@octokit/rest'; import { IIntegrationAuth } from '../models'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -40,6 +41,11 @@ const getApps = async ({ let apps: App[]; try { switch (integrationAuth.integration) { + case INTEGRATION_AZURE_KEY_VAULT: + apps = await getAppsAzureKeyVault({ + accessToken + }); + break; case INTEGRATION_HEROKU: apps = await getAppsHeroku({ accessToken @@ -81,6 +87,15 @@ const getApps = async ({ return apps; }; +const getAppsAzureKeyVault = async ({ + accessToken +}: { + accessToken: string; +}) => { + // TODO + return []; +} + /** * Return list of apps for Heroku integration * @param {Object} obj diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 26aca5fdb2..ada2b76bde 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -1,10 +1,12 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, INTEGRATION_GITHUB, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, @@ -12,15 +14,27 @@ import { } from '../variables'; import { SITE_URL, + CLIENT_ID_AZURE, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, CLIENT_ID_GITHUB, + CLIENT_SECRET_AZURE, CLIENT_SECRET_HEROKU, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, CLIENT_SECRET_GITHUB } from '../config'; +interface ExchangeCodeAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; +} + interface ExchangeCodeHerokuResponse { token_type: string; access_token: string; @@ -75,6 +89,11 @@ const exchangeCode = async ({ try { switch (integration) { + case INTEGRATION_AZURE_KEY_VAULT: + obj = await exchangeCodeAzure({ + code + }); + break; case INTEGRATION_HEROKU: obj = await exchangeCodeHeroku({ code @@ -105,6 +124,46 @@ const exchangeCode = async ({ return obj; }; +/** + * Return [accessToken] for Azure OAuth2 code-token exchange + * @param param0 + */ +const exchangeCodeAzure = async ({ + code +}: { + code: string; +}) => { + const accessExpiresAt = new Date(); + let res: ExchangeCodeAzureResponse; + try { + res = (await axios.post( + INTEGRATION_AZURE_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + scope: 'https://vault.azure.net/.default openid offline_access', // TODO: do we need all these permissions? + client_id: CLIENT_ID_AZURE, + client_secret: CLIENT_SECRET_AZURE, + redirect_uri: `${SITE_URL}/azure-key-vault` + } as any) + )).data; + + accessExpiresAt.setSeconds( + accessExpiresAt.getSeconds() + res.expires_in + ); + } catch (err: any) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Azure'); + } + + return ({ + accessToken: res.access_token, + refreshToken: res.refresh_token, + accessExpiresAt + }); +} + /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku * OAuth2 code-token exchange diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index ea232f1e5d..4fdbcdbbb4 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,13 +1,26 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; -import { INTEGRATION_HEROKU } from '../variables'; +import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU } from '../variables'; import { - CLIENT_SECRET_HEROKU + SITE_URL, + CLIENT_ID_AZURE, + CLIENT_SECRET_AZURE, + CLIENT_SECRET_HEROKU } from '../config'; import { - INTEGRATION_HEROKU_TOKEN_URL + INTEGRATION_AZURE_TOKEN_URL, + INTEGRATION_HEROKU_TOKEN_URL } from '../variables'; +interface RefreshTokenAzureResponse { + token_type: string; + scope: string; + expires_in: number; + ext_expires_in: 4871; + access_token: string; + refresh_token: string; +} + /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] @@ -25,6 +38,11 @@ const exchangeRefresh = async ({ let accessToken; try { switch (integration) { + case INTEGRATION_AZURE_KEY_VAULT: + accessToken = await exchangeRefreshAzure({ + refreshToken + }); + break; case INTEGRATION_HEROKU: accessToken = await exchangeRefreshHeroku({ refreshToken @@ -40,6 +58,38 @@ const exchangeRefresh = async ({ return accessToken; }; +/** + * Return new access token by exchanging refresh token [refreshToken] for the + * Azure integration + * @param {Object} obj + * @param {String} obj.refreshToken - refresh token to use to get new access token for Azure + * @returns + */ +const exchangeRefreshAzure = async ({ + refreshToken +}: { + refreshToken: string; +}) => { + try { + const res: RefreshTokenAzureResponse = (await axios.post( + INTEGRATION_AZURE_TOKEN_URL, + new URLSearchParams({ + client_id: CLIENT_ID_AZURE, + scope: 'openid offline_access', + refresh_token: refreshToken, + grant_type: 'refresh_token', + client_secret: CLIENT_SECRET_AZURE + } as any) + )).data; + + return res.access_token; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get refresh OAuth2 access token for Azure'); + } +} + /** * Return new access token by exchanging refresh token [refreshToken] for the * Heroku integration @@ -52,23 +102,23 @@ const exchangeRefreshHeroku = async ({ }: { refreshToken: string; }) => { - let accessToken; - //TODO: Refactor code to take advantage of using RequestError. It's possible to create new types of errors for more detailed errors - try { - const res = await axios.post( - INTEGRATION_HEROKU_TOKEN_URL, - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_secret: CLIENT_SECRET_HEROKU - } as any) - ); + + let accessToken; + try { + const res = await axios.post( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: CLIENT_SECRET_HEROKU + } as any) + ); accessToken = res.data.access_token; } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to get new OAuth2 access token for Heroku'); + throw new Error('Failed to refresh OAuth2 access token for Heroku'); } return accessToken; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 9954943b8d..4604d744a2 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -6,6 +6,7 @@ import sodium from 'libsodium-wrappers'; // const sodium = require('libsodium-wrappers'); import { IIntegration, IIntegrationAuth } from '../models'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -18,7 +19,6 @@ import { INTEGRATION_RENDER_API_URL, INTEGRATION_FLYIO_API_URL } from '../variables'; -import { access, appendFile } from 'fs'; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -41,6 +41,13 @@ const syncSecrets = async ({ }) => { try { switch (integration.integration) { + case INTEGRATION_AZURE_KEY_VAULT: + await syncSecretsAzureKeyVault({ + integration, + secrets, + accessToken + }); + break; case INTEGRATION_HEROKU: await syncSecretsHeroku({ integration, @@ -93,6 +100,151 @@ const syncSecrets = async ({ } }; +/** + * Sync/push [secrets] to Azure Key Vault with vault URI [integration.app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @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 Azure Key Vault integration + */ +const syncSecretsAzureKeyVault = async ({ + integration, + secrets, + accessToken +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + + interface GetAzureKeyVaultSecret { + id: string; // secret URI + attributes: { + enabled: true, + created: number; + updated: number; + recoveryLevel: string; + recoverableDays: number; + } + } + + interface AzureKeyVaultSecret extends GetAzureKeyVaultSecret { + key: string; + } + + /** + * Return all secrets from Azure Key Vault by paginating through URL [url] + * @param {String} url - pagination URL to get next set of secrets from Azure Key Vault + * @returns + */ + const paginateAzureKeyVaultSecrets = async (url: string) => { + let result: GetAzureKeyVaultSecret[] = []; + + while (url) { + const res = await axios.get(url, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + result = result.concat(res.data.value); + url = res.data.nextLink; + } + + return result; + } + + const getAzureKeyVaultSecrets = await paginateAzureKeyVaultSecrets(`${integration.app}/secrets?api-version=7.3`); + + let lastSlashIndex: number; + const res = (await Promise.all(getAzureKeyVaultSecrets.map(async (getAzureKeyVaultSecret) => { + if (!lastSlashIndex) { + lastSlashIndex = getAzureKeyVaultSecret.id.lastIndexOf('/'); + } + + const azureKeyVaultSecret = await axios.get(`${getAzureKeyVaultSecret.id}?api-version=7.3`, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + return ({ + ...azureKeyVaultSecret.data, + key: getAzureKeyVaultSecret.id.substring(lastSlashIndex + 1), + }); + }))) + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), {}); + + const setSecrets: { + key: string; + value: string; + }[] = []; + + Object.keys(secrets).forEach((key) => { + const hyphenatedKey = key.replace(/_/g, '-'); + if (!(hyphenatedKey in res)) { + // case: secret has been created + setSecrets.push({ + key: hyphenatedKey, + value: secrets[key] + }); + } else { + if (secrets[key] !== res[hyphenatedKey].value) { + // case: secret has been updated + setSecrets.push({ + key: hyphenatedKey, + value: secrets[key] + }); + } + } + }); + + const deleteSecrets: AzureKeyVaultSecret[] = []; + + Object.keys(res).forEach((key) => { + const underscoredKey = key.replace(/-/g, '_'); + if (!(underscoredKey in secrets)) { + deleteSecrets.push(res[key]); + } + }); + + // Sync/push set secrets + if (setSecrets.length > 0) { + setSecrets.forEach(async ({ key, value }) => { + await axios.put( + `${integration.app}/secrets/${key}?api-version=7.3`, + { + value + }, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret) => { + await axios.delete(`${integration.app}/secrets/${secret.key}?api-version=7.3`, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Azure Key Vault'); + } +}; + /** * Sync/push [secrets] to Heroku app named [integration.app] * @param {Object} obj diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 01e1d7ee3f..9c1214fb51 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,5 +1,6 @@ import { Schema, model, Types } from 'mongoose'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -17,7 +18,7 @@ export interface IIntegration { owner: string; targetEnvironment: string; appId: string; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault'; integrationAuth: Types.ObjectId; } @@ -59,6 +60,7 @@ const integrationSchema = new Schema( integration: { type: String, enum: [ + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index bff56f09ac..0eaa8c9e15 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,5 +1,6 @@ import { Schema, model, Types } from 'mongoose'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -9,7 +10,7 @@ import { export interface IIntegrationAuth { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio' | 'azure-key-vault'; teamId: string; accountId: string; refreshCiphertext?: string; @@ -31,6 +32,7 @@ const integrationAuthSchema = new Schema( integration: { type: String, enum: [ + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 4ee991cdde..cb452f8c88 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -1,7 +1,3 @@ -import * as Sentry from '@sentry/node'; -import { - Integration -} from '../models'; import { handleOAuthExchangeHelper, syncIntegrationsHelper, @@ -10,7 +6,6 @@ import { setIntegrationAuthRefreshHelper, setIntegrationAuthAccessHelper, } from '../helpers/integration'; -import { exchangeCode } from '../integrations'; // should sync stuff be here too? Probably. // TODO: move bot functions to IntegrationService. @@ -26,11 +21,15 @@ class IntegrationService { * - 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.environment - workspace environment - * @param {String} obj.integration - name of integration - * @param {String} obj.code - code + * @param {Object} obj1 + * @param {String} obj1.workspaceId - id of workspace + * @param {String} obj1.environment - workspace environment + * @param {String} obj1.integration - name of integration + * @param {String} obj1.code - code + * @returns {Object} obj2 + * @returns {IntegrationAuth} obj2.integrationAuth - integration authorization after OAuth2 code-token exchange + * @returns {Integration} obj2.integration - newly-initialized integration OAuth2 code-token exchange + * @retrun */ static async handleOAuthExchange({ workspaceId, @@ -43,7 +42,7 @@ class IntegrationService { code: string; environment: string; }) { - await handleOAuthExchangeHelper({ + return await handleOAuthExchangeHelper({ workspaceId, integration, code, diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index fafab69392..cf061520c9 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -6,6 +6,7 @@ import { ENV_SET } from './environment'; import { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -14,6 +15,7 @@ import { INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, @@ -58,6 +60,7 @@ export { ENV_STAGING, ENV_PROD, ENV_SET, + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -66,6 +69,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 7cecb54c2c..0cac80984a 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -1,3 +1,7 @@ +import { + CLIENT_ID_AZURE, + TENANT_ID_AZURE +} from '../config'; import { CLIENT_ID_HEROKU, CLIENT_ID_NETLIFY, @@ -6,6 +10,7 @@ import { } from '../config'; // integrations +const INTEGRATION_AZURE_KEY_VAULT = 'azure-key-vault'; const INTEGRATION_HEROKU = 'heroku'; const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_NETLIFY = 'netlify'; @@ -13,6 +18,7 @@ const INTEGRATION_GITHUB = 'github'; const INTEGRATION_RENDER = 'render'; const INTEGRATION_FLYIO = 'flyio'; const INTEGRATION_SET = new Set([ + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -25,6 +31,7 @@ const INTEGRATION_SET = new Set([ const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints +const INTEGRATION_AZURE_TOKEN_URL = `https://login.microsoftonline.com/${TENANT_ID_AZURE}/oauth2/v2.0/token`; const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token'; @@ -40,6 +47,16 @@ const INTEGRATION_RENDER_API_URL = 'https://api.render.com'; const INTEGRATION_FLYIO_API_URL = 'https://api.fly.io/graphql'; const INTEGRATION_OPTIONS = [ + { + name: 'Azure Key Vault', + slug: 'azure-key-vault', + image: 'Microsoft Azure.png', + isAvailable: true, + type: 'oauth', + clientId: CLIENT_ID_AZURE, + tenantId: TENANT_ID_AZURE, + docsLink: '' + }, { name: 'Heroku', slug: 'heroku', @@ -143,6 +160,7 @@ const INTEGRATION_OPTIONS = [ ] export { + INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, INTEGRATION_NETLIFY, @@ -151,6 +169,7 @@ export { INTEGRATION_FLYIO, INTEGRATION_SET, INTEGRATION_OAUTH2, + INTEGRATION_AZURE_TOKEN_URL, INTEGRATION_HEROKU_TOKEN_URL, INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 2ff525e541..6ac74cdbdc 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -2,6 +2,16 @@ interface Mapping { [key: string]: string; } +const integrationSlugNameMapping: Mapping = { + 'azure-key-vault': 'Azure Key Vault', + 'heroku': 'Heroku', + 'vercel': 'Vercel', + 'netlify': 'Netlify', + 'github': 'GitHub', + 'render': 'Render', + 'flyio': 'Fly.io' +} + const envMapping: Mapping = { Development: "dev", Staging: "staging", @@ -49,6 +59,7 @@ const plans = plansProd || plansDev; export { contextNetlifyMapping, envMapping, + integrationSlugNameMapping, plans, reverseContextNetlifyMapping, reverseEnvMapping} diff --git a/frontend/src/components/basic/Layout.tsx b/frontend/src/components/basic/Layout.tsx index ba4cf73b2e..1f1ee246df 100644 --- a/frontend/src/components/basic/Layout.tsx +++ b/frontend/src/components/basic/Layout.tsx @@ -199,13 +199,13 @@ const Layout = ({ children }: LayoutProps) => { .split('/') [router.asPath.split('/').length - 1].split('?')[0]; - if (!['heroku', 'vercel', 'github', 'netlify'].includes(intendedWorkspaceId)) { + if (!['heroku', 'vercel', 'github', 'netlify', 'azure-key-vault'].includes(intendedWorkspaceId)) { localStorage.setItem('projectData.id', intendedWorkspaceId); } // If a user is not a member of a workspace they are trying to access, just push them to one of theirs if ( - !['heroku', 'vercel', 'github', 'netlify'].includes(intendedWorkspaceId) && + !['heroku', 'vercel', 'github', 'netlify', 'azure-key-vault'].includes(intendedWorkspaceId) && !userWorkspaces .map((workspace: { _id: string }) => workspace._id) .includes(intendedWorkspaceId) diff --git a/frontend/src/components/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index abec74f9d4..6bbecad43d 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/router'; import { faArrowRight, faRotate, faX } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; // TODO: This needs to be moved from public folder -import { contextNetlifyMapping, reverseContextNetlifyMapping } from 'public/data/frequentConstants'; +import { contextNetlifyMapping, integrationSlugNameMapping, reverseContextNetlifyMapping } from 'public/data/frequentConstants'; import Button from '@app/components/basic/buttons/Button'; import ListBox from '@app/components/basic/Listbox'; @@ -52,6 +52,7 @@ const IntegrationTile = ({ environments = [], handleDeleteIntegration }: Props) => { + // set initial environment. This find will only execute when component is mounting const [integrationEnvironment, setIntegrationEnvironment] = useState( environments.find(({ slug }) => slug === integration.environment) || { @@ -72,7 +73,14 @@ const IntegrationTile = ({ }); setApps(tempApps); - setIntegrationApp(integration.app ? integration.app : tempApps[0].name); + + if (integration?.app) { + setIntegrationApp(integration.app); + } else if (tempApps.length > 0) { + setIntegrationApp(tempApps[0].name) + } else { + setIntegrationApp(''); + } switch (integration.integration) { case 'vercel': @@ -174,7 +182,7 @@ const IntegrationTile = ({ return
; }; - if (!integrationApp || apps.length === 0) return
; + if (!integrationApp) return
; return (
@@ -201,7 +209,8 @@ const IntegrationTile = ({

INTEGRATION

- {integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)} + {/* {integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)} */} + {integrationSlugNameMapping[integration.integration]}
diff --git a/frontend/src/pages/api/integrations/authorizeIntegration.ts b/frontend/src/pages/api/integrations/authorizeIntegration.ts index 94f1d54f5c..666499be6d 100644 --- a/frontend/src/pages/api/integrations/authorizeIntegration.ts +++ b/frontend/src/pages/api/integrations/authorizeIntegration.ts @@ -26,7 +26,7 @@ const AuthorizeIntegration = ({ workspaceId, code, integration }: Props) => }) }).then(async (res) => { if (res && res.status === 200) { - return res; + return (res.json()); } console.log('Failed to authorize the integration'); return undefined; diff --git a/frontend/src/pages/azure-key-vault.tsx b/frontend/src/pages/azure-key-vault.tsx new file mode 100644 index 0000000000..57f8613870 --- /dev/null +++ b/frontend/src/pages/azure-key-vault.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from '../components/v2'; +import AuthorizeIntegration from './api/integrations/authorizeIntegration'; +import updateIntegration from './api/integrations/updateIntegration'; +import getAWorkspace from './api/workspace/getAWorkspace'; + +interface Integration { + _id: string; + isActive: boolean; + app: string | null; + appId: string | null; + createdAt: string; + updatedAt: string; + environment: string; + integration: string; + targetEnvironment: string; + workspace: string; + integrationAuth: string; +} + +export default function AzureKeyVault() { + const router = useRouter(); + + // query-string variables + const parsedUrl = queryString.parse(router.asPath.split('?')[1]); + const {code} = parsedUrl; + const {state} = parsedUrl; + + const [integration, setIntegration] = useState(null); + const [environments, setEnvironments] = useState< + { + name: string; + slug: string; + }[] + >([]); + const [environment, setEnvironment] = useState(''); + const [vaultBaseUrl, setVaultBaseUrl] = useState(''); + const [vaultBaseUrlErrorText, setVaultBaseUrlErrorText] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + (async () => { + try { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + + const integrationDetails = await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id') as string, + code: code as string, + integration: 'azure-key-vault', + }); + + setIntegration(integrationDetails.integration); + + const workspaceId = localStorage.getItem('projectData.id'); + if (!workspaceId) return; + + const workspace = await getAWorkspace(workspaceId); + setEnvironment(workspace.environments[0].slug); + setEnvironments(workspace.environments); + + } + } catch (error) { + console.error('Azure Key Vault integration error: ', error); + } + })(); + }, []); + + const handleButtonClick = async () => { + try { + if (vaultBaseUrl.length === 0) { + setVaultBaseUrlErrorText('Vault URI cannot be blank'); + return; + } + + if ( + !vaultBaseUrl.startsWith('https://') + || !vaultBaseUrl.endsWith('vault.azure.net') + ) { + setVaultBaseUrlErrorText('Vault URI must be like https://.vault.azure.net'); + return; + } + + if (!integration) return; + + setIsLoading(true); + await updateIntegration({ + integrationId: integration._id, + isActive: true, + environment, + app: vaultBaseUrl, + appId: null, + targetEnvironment: null, + owner: null + }); + setIsLoading(false); + + router.push( + `/integrations/${localStorage.getItem('projectData.id')}` + ); + + } catch (err) { + console.error(err); + } + } + + return (integration && environments.length > 0) ? ( +
+ + Azure Key Vault Integration + + + + + setVaultBaseUrl(e.target.value)} + /> + + + +
+ ) :
+} + +AzureKeyVault.requireAuth = true; + +export const getServerSideProps = getTranslatedServerSideProps(['integrations']); \ No newline at end of file diff --git a/frontend/src/pages/github.tsx b/frontend/src/pages/github.tsx index 8970272463..37c35b1946 100644 --- a/frontend/src/pages/github.tsx +++ b/frontend/src/pages/github.tsx @@ -25,7 +25,7 @@ export default function Github() { integration: 'github', }); router.push( - `/integrations/${ localStorage.getItem('projectData.id')}` + `/integrations/${localStorage.getItem('projectData.id')}` ); } } catch (error) { diff --git a/frontend/src/pages/integrations/[id].tsx b/frontend/src/pages/integrations/[id].tsx index a4bf83a897..688d7c34fc 100644 --- a/frontend/src/pages/integrations/[id].tsx +++ b/frontend/src/pages/integrations/[id].tsx @@ -195,6 +195,11 @@ export default function Integrations() { localStorage.setItem('latestCSRFToken', state); switch (integrationOption.slug) { + case 'azure-key-vault': + window.location.assign( + `https://login.microsoftonline.com/${integrationOption.tenantId}/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/azure-key-vault&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}` + ); + break; case 'heroku': window.location.assign( `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}` @@ -275,10 +280,15 @@ export default function Integrations() { // case: integration has been authorized before // -> create new integration - const integration = await createIntegration({ - integrationAuthId: integrationAuthX._id - }); - setIntegrations([...integrations, integration]); + + if (!['azure-key-vault'].includes(integrationOption.slug)) { + const integration = await createIntegration({ + integrationAuthId: integrationAuthX._id + }); + setIntegrations([...integrations, integration]); + } else { + handleIntegrationOption({ integrationOption }); + } } catch (err) { console.error(err); }