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 000000000..9490ffc6d Binary files /dev/null and b/frontend/public/images/integrations/GitHub.png differ