From 7763e33de6c7abf059a6c33112f93174ea90c0c0 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 21 Dec 2022 23:15:22 -0500 Subject: [PATCH] Fix errors and complete v1 full-loop of GitHub integration with repository secrets --- .env.example | 2 + backend/package-lock.json | 7 - backend/src/config/index.ts | 5 +- backend/src/integrations/apps.ts | 18 ++- backend/src/integrations/exchange.ts | 51 +----- backend/src/integrations/refresh.ts | 13 -- backend/src/integrations/sync.ts | 150 +++++++++--------- backend/src/variables/integration.ts | 13 +- .../components/integrations/Integration.tsx | 2 +- frontend/pages/integrations/[id].js | 13 +- .../public/images/integrations/GitHub.png | Bin 0 -> 8640 bytes 11 files changed, 119 insertions(+), 155 deletions(-) create mode 100644 frontend/public/images/integrations/GitHub.png diff --git a/.env.example b/.env.example index ec832f9e9..1dc215e86 100644 --- a/.env.example +++ b/.env.example @@ -50,9 +50,11 @@ SMTP_PASSWORD= CLIENT_ID_HEROKU= CLIENT_ID_VERCEL= CLIENT_ID_NETLIFY= +CLIENT_ID_GITHUB= CLIENT_SECRET_HEROKU= CLIENT_SECRET_VERCEL= CLIENT_SECRET_NETLIFY= +CLIENT_SECRET_GITHUB= # Sentry (optional) for monitoring errors SENTRY_DSN= diff --git a/backend/package-lock.json b/backend/package-lock.json index 693f055a6..b3e8f4c90 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -3921,7 +3921,6 @@ "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", - "fsevents": "~2.3.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", @@ -5133,7 +5132,6 @@ "minimist": "^1.2.5", "neo-async": "^2.6.0", "source-map": "^0.6.1", - "uglify-js": "^3.1.4", "wordwrap": "^1.0.0" }, "bin": { @@ -5791,7 +5789,6 @@ "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", "graceful-fs": "^4.2.9", "jest-regex-util": "^29.2.0", "jest-util": "^29.3.1", @@ -6595,11 +6592,9 @@ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.11.0.tgz", "integrity": "sha512-9l9n4Nk2BYZzljW3vHah3Z0rfS5npKw6ktnkmFgTcnzaXH1DRm3pDl6VMHu84EVb1lzmSaJC4OzWZqTkB5i2wg==", "dependencies": { - "@aws-sdk/credential-providers": "^3.186.0", "bson": "^4.7.0", "denque": "^2.1.0", "mongodb-connection-string-url": "^2.5.4", - "saslprep": "^1.0.3", "socks": "^2.7.1" }, "engines": { @@ -7682,7 +7677,6 @@ "inBundle": true, "license": "MIT", "dependencies": { - "@colors/colors": "1.5.0", "string-width": "^4.2.0" }, "engines": { @@ -8478,7 +8472,6 @@ "inBundle": true, "license": "MIT", "dependencies": { - "encoding": "^0.1.13", "minipass": "^3.1.6", "minipass-sized": "^1.0.3", "minizlib": "^2.1.2" diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 99c72ab3d..525a7b2c8 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -14,11 +14,10 @@ const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!; 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! || 'e787fc24bcec43ecd5d5'; +const CLIENT_ID_GITHUB = process.env.CLIENT_ID_GITHUB!; const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!; const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!; -const CLIENT_SECRET_GITHUB = process.env.CLIENT_SECRET_GITHUB! || '407f32da788f63559abd662c6de08bb2911ca8ae'; +const CLIENT_SECRET_GITHUB = process.env.CLIENT_SECRET_GITHUB!; const CLIENT_SLUG_VERCEL= process.env.CLIENT_SLUG_VERCEL!; const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com'; const POSTHOG_PROJECT_API_KEY = diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index c4e53c1f8..e3b78c481 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -13,6 +13,10 @@ import { INTEGRATION_GITHUB_API_URL } from '../variables'; +interface GitHubApp { + name: string; +} + /** * Return list of names of apps for integration named [integration] * @param {Object} obj @@ -186,13 +190,17 @@ const getAppsGithub = async ({ auth: accessToken }); - const repos = await octokit.request( + const repos = (await octokit.request( 'GET /user/repos{?visibility,affiliation,type,sort,direction,per_page,page,since,before}', {} - ); - apps = repos.map((a: any) => { - a.name; - }); + )).data; + + apps = repos + .filter((a:any) => a.permissions.admin === true) + .map((a: any) => ({ + name: a.name + }) + ); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 3b7526200..dafddc785 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -9,14 +9,15 @@ import { INTEGRATION_VERCEL_TOKEN_URL, INTEGRATION_NETLIFY_TOKEN_URL, INTEGRATION_GITHUB_TOKEN_URL, + INTEGRATION_GITHUB_API_URL, ACTION_PUSH_TO_HEROKU } from '../variables'; - import { SITE_URL, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, CLIENT_ID_GITHUB, + CLIENT_SECRET_HEROKU, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, CLIENT_SECRET_GITHUB @@ -117,43 +118,13 @@ const exchangeCode = async ({ * @returns {String} obj2.refreshToken - refresh token for Heroku API * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ -<<<<<<< HEAD -const exchangeCodeHeroku = async ({ code }: { code: string }) => { - let res: ExchangeCodeHerokuResponse; - const accessExpiresAt = new Date(); - try { - res = ( - await axios.post( - INTEGRATION_HEROKU_TOKEN_URL, - new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_secret: OAUTH_CLIENT_SECRET_HEROKU - } as any) - ) - ).data; - - accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + res.expires_in); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Heroku'); - } - - return { - accessToken: res.access_token, - refreshToken: res.refresh_token, - accessExpiresAt - }; -}; -======= const exchangeCodeHeroku = async ({ code }: { code: string; }) => { let res: ExchangeCodeHerokuResponse; - let accessExpiresAt = new Date(); + const accessExpiresAt = new Date(); try { res = (await axios.post( INTEGRATION_HEROKU_TOKEN_URL, @@ -179,7 +150,6 @@ const exchangeCodeHeroku = async ({ accessExpiresAt }); } ->>>>>>> 5444382d5ae1fabf1107434a856b58b9f09c67f6 /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel @@ -286,7 +256,6 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => { */ const exchangeCodeGithub = async ({ code }: { code: string }) => { let res: ExchangeCodeGithubResponse; - let res2; try { res = ( await axios.get(INTEGRATION_GITHUB_TOKEN_URL, { @@ -295,29 +264,21 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { client_secret: CLIENT_SECRET_GITHUB, code: code, redirect_uri: `${SITE_URL}/github` + }, + headers: { + Accept: 'application/json' } }) ).data; - res2 = ( - await axios.get(INTEGRATION_GITHUB_TOKEN_URL, { - params: { - Authorization: `Bearer ${res.access_token}` - } - }) - ).data; } catch (err) { Sentry.setUser(null); Sentry.captureException(err); throw new Error('Failed OAuth2 code-token exchange with Github'); } - // TODO: Check actual response and fix next line - const accountId = res2.user; - return { accessToken: res.access_token, - user: accountId, refreshToken: null, accessExpiresAt: null }; diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 382d3ca24..8ddb6a651 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -52,18 +52,6 @@ const exchangeRefreshHeroku = async ({ }: { refreshToken: string; }) => { -<<<<<<< HEAD - let accessToken; - try { - const res = await axios.post( - INTEGRATION_HEROKU_TOKEN_URL, - new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_secret: OAUTH_CLIENT_SECRET_HEROKU - } as any) - ); -======= let accessToken; try { const res = await axios.post( @@ -74,7 +62,6 @@ const exchangeRefreshHeroku = async ({ client_secret: CLIENT_SECRET_HEROKU } as any) ); ->>>>>>> 5444382d5ae1fabf1107434a856b58b9f09c67f6 accessToken = res.data.access_token; } catch (err) { diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 9c6b66674..30628fb9a 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,7 +1,9 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { Octokit } from '@octokit/rest'; -import * as sodium from 'libsodium-wrappers'; +// import * as sodium from 'libsodium-wrappers'; +import sodium from 'libsodium-wrappers'; +// const sodium = require('libsodium-wrappers'); import { IIntegration, IIntegrationAuth } from '../models'; import { INTEGRATION_HEROKU, @@ -66,11 +68,11 @@ const syncSecrets = async ({ case INTEGRATION_GITHUB: await syncSecretsGitHub({ integration, - integrationAuth, secrets, accessToken }); break; + } } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -486,7 +488,7 @@ const syncSecretsNetlify = async ({ } /** - * Sync/push [secrets] to GitHub site [app] + * Sync/push [secrets] to GitHub [repo] * @param {Object} obj * @param {IIntegration} obj.integration - integration details * @param {IIntegrationAuth} obj.integrationAuth - integration auth details @@ -494,106 +496,110 @@ const syncSecretsNetlify = async ({ */ const syncSecretsGitHub = async ({ integration, - integrationAuth, secrets, accessToken }: { integration: IIntegration; - integrationAuth: IIntegrationAuth; secrets: any; accessToken: string; }) => { try { - const deleteSecrets: Array = []; + + interface GitHubRepoKey { + key_id: string; + key: string; + } + + interface GitHubSecret { + name: string; + created_at: string; + updated_at: string; + } + + interface GitHubSecretRes { + [index: string]: GitHubSecret; + } + + const deleteSecrets: GitHubSecret[] = []; const octokit = new Octokit({ auth: accessToken }); - const loggedInUser = await octokit.request('GET /user', {}); - // TODO: Check loggedInUser.login == repo owner - const repoPublicKey = await octokit.request( + const user = (await octokit.request('GET /user', {})).data; + + const repoPublicKey: GitHubRepoKey = (await octokit.request( 'GET /repos/{owner}/{repo}/actions/secrets/public-key', { - owner: loggedInUser.login, + owner: user.login, repo: integration.app } - ).key; + )).data; - const userRepos = await octokit.request('GET /user/repos', {}); - - // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key - const encryptedSecrets = await octokit.request( + // // Get local copy of decrypted secrets. We cannot decrypt them as we dont have access to GH private key + const encryptedSecrets: GitHubSecretRes = (await octokit.request( 'GET /repos/{owner}/{repo}/actions/secrets', { - owner: loggedInUser.name, + owner: user.login, repo: integration.app } - ); - - Object.keys(secrets).map((key) => { - if (!(key in encryptedSecrets)) { - deleteSecrets.push(key); - } - }); - - if (!Object.values(userRepos).includes(integration.app)) { - if (deleteSecrets.length == 0) { - throw new Error('Failed to sync secrets to Github'); - } - } - - // Sync/push all secrets - for (const i in secrets) { - let encryptedSecret; - sodium.ready.then(() => { - // Convert Secret & Base64 key to Uint8Array. - const binkey = sodium.from_base64( - repoPublicKey, - sodium.base64_variants.ORIGINAL - ); - const binsec = sodium.from_string(secrets[i]); - - //Encrypt the secret using LibSodium - const encBytes = sodium.crypto_box_seal(binsec, binkey); - - // Convert encrypted Uint8Array to Base64 - encryptedSecret = sodium.to_base64( - encBytes, - sodium.base64_variants.ORIGINAL - ); - }); - - const res = await octokit.request( - 'PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}', - { - owner: loggedInUser.login, - repo: integration.app, - secret_name: Object.keys(secrets[i]), - encrypted_value: encryptedSecret, - key_id: '' //TODO: Not sure if we need this? https://docs.github.com/en/rest/actions/secrets?apiVersion=2022-11-28#create-or-update-a-repository-secret - } - ); - } - - // Delete secrets - if (deleteSecrets.length > 0) { - for (const i in deleteSecrets) { - const res = await octokit.request( + )) + .data + .secrets + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.name]: secret + }), {}); + + Object.keys(encryptedSecrets).map(async (key) => { + if (!(key in secrets)) { + await octokit.request( 'DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}', { - owner: loggedInUser.login, + owner: user.login, repo: integration.app, - secret_name: secrets[i] + secret_name: key } ); } - } + }); + + Object.keys(secrets).map((key) => { + // let encryptedSecret; + sodium.ready.then(async () => { + // convert secret & base64 key to Uint8Array. + const binkey = sodium.from_base64( + repoPublicKey.key, + sodium.base64_variants.ORIGINAL + ); + const binsec = sodium.from_string(secrets[key]); + + // encrypt secret using libsodium + const encBytes = sodium.crypto_box_seal(binsec, binkey); + + // convert encrypted Uint8Array to base64 + const encryptedSecret = sodium.to_base64( + encBytes, + sodium.base64_variants.ORIGINAL + ); + + await octokit.request( + 'PUT /repos/{owner}/{repo}/actions/secrets/{secret_name}', + { + owner: user.login, + repo: integration.app, + secret_name: key, + encrypted_value: encryptedSecret, + key_id: repoPublicKey.key_id + } + ); + }); + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); - throw new Error('Failed to sync secrets to Github'); + throw new Error('Failed to sync secrets to GitHub'); } }; -export { syncSecrets }; +export { syncSecrets }; \ No newline at end of file diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 2e0a8182b..00e817c57 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -1,6 +1,7 @@ import { CLIENT_ID_HEROKU, CLIENT_ID_NETLIFY, + CLIENT_ID_GITHUB, CLIENT_SLUG_VERCEL } from '../config'; @@ -31,7 +32,7 @@ const INTEGRATION_GITHUB_TOKEN_URL = const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com'; const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com'; const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com'; -const INTEGRATION_GITHUB_API_URL = ' https://api.github.com/'; +const INTEGRATION_GITHUB_API_URL = 'https://api.github.com'; const INTEGRATION_OPTIONS = [ { @@ -62,6 +63,16 @@ const INTEGRATION_OPTIONS = [ clientId: CLIENT_ID_NETLIFY, docsLink: '' }, + { + name: 'GitHub', + slug: 'github', + image: 'GitHub', + isAvailable: true, + type: 'oauth2', + clientId: CLIENT_ID_GITHUB, + docsLink: '' + + }, { name: 'Google Cloud Platform', slug: 'gcp', diff --git a/frontend/components/integrations/Integration.tsx b/frontend/components/integrations/Integration.tsx index 053269be4..3bba41534 100644 --- a/frontend/components/integrations/Integration.tsx +++ b/frontend/components/integrations/Integration.tsx @@ -203,7 +203,7 @@ const Integration = ({ context: integrationContext ? reverseContextNetlifyMapping[integrationContext] : null, siteId }); - + router.reload(); }} color="mineshaft" diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index c47822c80..32a8e515e 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -123,6 +123,8 @@ export default function Integrations() { * @returns */ const handleIntegrationOption = async ({ integrationOption }) => { + + console.log('handleIntegrationOption', integrationOption); try { // generate CSRF token for OAuth2 code-token exchange integrations @@ -137,16 +139,11 @@ export default function Integrations() { window.location = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`; break; case 'Netlify': - window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; - break; - case 'Github': - window.location = `https://github.com.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${integrationOption.redirectURL}&state=${state}`; - break; - case 'Fly.io': - console.log('fly.io'); - setIntegrationAccessTokenDialogOpen(true); window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`; break; + case 'GitHub': + window.location = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}`; + break; // case 'Fly.io': // console.log('fly.io'); // setIntegrationAccessTokenDialogOpen(true); diff --git a/frontend/public/images/integrations/GitHub.png b/frontend/public/images/integrations/GitHub.png new file mode 100644 index 0000000000000000000000000000000000000000..9490ffc6d2e158b266f719d58365905c1c5060cb GIT binary patch literal 8640 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 literal 0 HcmV?d00001