From 75020568d3dcbe9c94be924de127c4e7d2734d69 Mon Sep 17 00:00:00 2001 From: George Gale Date: Sun, 18 Dec 2022 17:45:31 +0100 Subject: [PATCH 1/2] Started Github integration. --- backend/package-lock.json | 408 ++++++++++++ backend/package.json | 3 + backend/src/config/index.ts | 7 + backend/src/integrations/apps.ts | 282 +++++---- backend/src/integrations/exchange.ts | 400 ++++++------ backend/src/integrations/refresh.ts | 98 ++- backend/src/integrations/revoke.ts | 77 ++- backend/src/integrations/sync.ts | 853 +++++++++++++++----------- backend/src/models/integration.ts | 138 +++-- backend/src/models/integrationAuth.ts | 144 ++--- backend/src/variables/index.ts | 133 ++-- backend/src/variables/integration.ts | 41 +- 12 files changed, 1613 insertions(+), 971 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index c13f138d4..8330bfefc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,9 +10,11 @@ "license": "ISC", "dependencies": { "@godaddy/terminus": "^4.11.2", + "@octokit/rest": "^19.0.5", "@sentry/node": "^7.14.0", "@sentry/tracing": "^7.19.0", "@types/crypto-js": "^4.1.1", + "@types/libsodium-wrappers": "^0.7.10", "axios": "^1.1.3", "bigint-conversion": "^2.2.2", "cookie-parser": "^1.4.6", @@ -26,6 +28,7 @@ "helmet": "^5.1.1", "jsonwebtoken": "^8.5.1", "jsrp": "^0.2.4", + "libsodium-wrappers": "^0.7.10", "mongoose": "^6.7.2", "nodemailer": "^6.8.0", "posthog-node": "^2.1.0", @@ -2593,6 +2596,153 @@ "node": ">= 8" } }, + "node_modules/@octokit/auth-token": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.2.tgz", + "integrity": "sha512-pq7CwIMV1kmzkFTimdwjAINCXKTajZErLB4wMLYapR2nuB/Jpr66+05wOTZMSCBXP6n4DdDWT2W19Bm17vU69Q==", + "dependencies": { + "@octokit/types": "^8.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.1.0.tgz", + "integrity": "sha512-Czz/59VefU+kKDy+ZfDwtOIYIkFjExOKf+HA92aiTZJ6EfWpFzYQWw0l54ji8bVmyhc+mGaLUbSUmXazG7z5OQ==", + "dependencies": { + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^8.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/endpoint": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.3.tgz", + "integrity": "sha512-57gRlb28bwTsdNXq+O3JTQ7ERmBTuik9+LelgcLIVfYwf235VHbN9QNo4kXExtp/h8T423cR5iJThKtFYxC7Lw==", + "dependencies": { + "@octokit/types": "^8.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/graphql": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.4.tgz", + "integrity": "sha512-amO1M5QUQgYQo09aStR/XO7KAl13xpigcy/kI8/N1PnZYSS69fgte+xA4+c2DISKqUZfsh0wwjc2FaCt99L41A==", + "dependencies": { + "@octokit/request": "^6.0.0", + "@octokit/types": "^8.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-14.0.0.tgz", + "integrity": "sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-5.0.1.tgz", + "integrity": "sha512-7A+rEkS70pH36Z6JivSlR7Zqepz3KVucEFVDnSrgHXzG7WLAzYwcHZbKdfTXHwuTHbkT1vKvz7dHl1+HNf6Qyw==", + "dependencies": { + "@octokit/types": "^8.0.0" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=4" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-6.7.0.tgz", + "integrity": "sha512-orxQ0fAHA7IpYhG2flD2AygztPlGYNAdlzYz8yrD8NDgelPfOYoRPROfEyIe035PlxvbYrgkfUZIhSBKju/Cvw==", + "dependencies": { + "@octokit/types": "^8.0.0", + "deprecation": "^2.3.1" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@octokit/request": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.2.tgz", + "integrity": "sha512-6VDqgj0HMc2FUX2awIs+sM6OwLgwHvAi4KCK3mT2H2IKRt6oH9d0fej5LluF5mck1lRR/rFWN0YIDSYXYSylbw==", + "dependencies": { + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^8.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/request-error": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.2.tgz", + "integrity": "sha512-WMNOFYrSaX8zXWoJg9u/pKgWPo94JXilMLb2VManNOby9EZxrQaBe/QSC4a1TzpAlpxofg2X/jMnCyZgL6y7eg==", + "dependencies": { + "@octokit/types": "^8.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/rest": { + "version": "19.0.5", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.5.tgz", + "integrity": "sha512-+4qdrUFq2lk7Va+Qff3ofREQWGBeoTKNqlJO+FGjFP35ZahP+nBenhZiGdu8USSgmq4Ky3IJ/i4u0xbLqHaeow==", + "dependencies": { + "@octokit/core": "^4.1.0", + "@octokit/plugin-paginate-rest": "^5.0.0", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^6.7.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@octokit/types": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-8.0.0.tgz", + "integrity": "sha512-65/TPpOJP1i3K4lBJMnWqPUJ6zuOtzhtagDvydAWbEXpbFYA0oMKKyLb95NFZZP0lSh/4b6K+DQlzvYQJQQePg==", + "dependencies": { + "@octokit/openapi-types": "^14.0.0" + } + }, "node_modules/@posthog/plugin-scaffold": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@posthog/plugin-scaffold/-/plugin-scaffold-1.3.4.tgz", @@ -2898,6 +3048,11 @@ "@types/node": "*" } }, + "node_modules/@types/libsodium-wrappers": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz", + "integrity": "sha512-BqI9B92u+cM3ccp8mpHf+HzJ8fBlRwdmyd6+fz3p99m3V6ifT5O3zmOMi612PGkpeFeG/G6loxUnzlDNhfjPSA==" + }, "node_modules/@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -3494,6 +3649,11 @@ } ] }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "node_modules/bigint-conversion": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/bigint-conversion/-/bigint-conversion-2.2.2.tgz", @@ -4062,6 +4222,11 @@ "node": ">= 0.8" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5343,6 +5508,14 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6168,6 +6341,19 @@ "node": ">= 0.8.0" } }, + "node_modules/libsodium": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.10.tgz", + "integrity": "sha512-eY+z7hDrDKxkAK+QKZVNv92A5KYkxfvIshtBJkmg5TSiCnYqZP3i9OO9whE79Pwgm4jGaoHgkM4ao/b9Cyu4zQ==" + }, + "node_modules/libsodium-wrappers": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz", + "integrity": "sha512-pO3F1Q9NPLB/MWIhehim42b/Fwb30JNScCNh8TcQ/kIc+qGLQch8ag8wb0keK3EP5kbGakk1H8Wwo7v+36rNQg==", + "dependencies": { + "libsodium": "^0.7.0" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6552,6 +6738,44 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10694,6 +10918,11 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "node_modules/universal-user-agent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -13203,6 +13432,118 @@ "fastq": "^1.6.0" } }, + "@octokit/auth-token": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-3.0.2.tgz", + "integrity": "sha512-pq7CwIMV1kmzkFTimdwjAINCXKTajZErLB4wMLYapR2nuB/Jpr66+05wOTZMSCBXP6n4DdDWT2W19Bm17vU69Q==", + "requires": { + "@octokit/types": "^8.0.0" + } + }, + "@octokit/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.1.0.tgz", + "integrity": "sha512-Czz/59VefU+kKDy+ZfDwtOIYIkFjExOKf+HA92aiTZJ6EfWpFzYQWw0l54ji8bVmyhc+mGaLUbSUmXazG7z5OQ==", + "requires": { + "@octokit/auth-token": "^3.0.0", + "@octokit/graphql": "^5.0.0", + "@octokit/request": "^6.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^8.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/endpoint": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-7.0.3.tgz", + "integrity": "sha512-57gRlb28bwTsdNXq+O3JTQ7ERmBTuik9+LelgcLIVfYwf235VHbN9QNo4kXExtp/h8T423cR5iJThKtFYxC7Lw==", + "requires": { + "@octokit/types": "^8.0.0", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/graphql": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-5.0.4.tgz", + "integrity": "sha512-amO1M5QUQgYQo09aStR/XO7KAl13xpigcy/kI8/N1PnZYSS69fgte+xA4+c2DISKqUZfsh0wwjc2FaCt99L41A==", + "requires": { + "@octokit/request": "^6.0.0", + "@octokit/types": "^8.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/openapi-types": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-14.0.0.tgz", + "integrity": "sha512-HNWisMYlR8VCnNurDU6os2ikx0s0VyEjDYHNS/h4cgb8DeOxQ0n72HyinUtdDVxJhFy3FWLGl0DJhfEWk3P5Iw==" + }, + "@octokit/plugin-paginate-rest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-5.0.1.tgz", + "integrity": "sha512-7A+rEkS70pH36Z6JivSlR7Zqepz3KVucEFVDnSrgHXzG7WLAzYwcHZbKdfTXHwuTHbkT1vKvz7dHl1+HNf6Qyw==", + "requires": { + "@octokit/types": "^8.0.0" + } + }, + "@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "requires": {} + }, + "@octokit/plugin-rest-endpoint-methods": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-6.7.0.tgz", + "integrity": "sha512-orxQ0fAHA7IpYhG2flD2AygztPlGYNAdlzYz8yrD8NDgelPfOYoRPROfEyIe035PlxvbYrgkfUZIhSBKju/Cvw==", + "requires": { + "@octokit/types": "^8.0.0", + "deprecation": "^2.3.1" + } + }, + "@octokit/request": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-6.2.2.tgz", + "integrity": "sha512-6VDqgj0HMc2FUX2awIs+sM6OwLgwHvAi4KCK3mT2H2IKRt6oH9d0fej5LluF5mck1lRR/rFWN0YIDSYXYSylbw==", + "requires": { + "@octokit/endpoint": "^7.0.0", + "@octokit/request-error": "^3.0.0", + "@octokit/types": "^8.0.0", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "@octokit/request-error": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-3.0.2.tgz", + "integrity": "sha512-WMNOFYrSaX8zXWoJg9u/pKgWPo94JXilMLb2VManNOby9EZxrQaBe/QSC4a1TzpAlpxofg2X/jMnCyZgL6y7eg==", + "requires": { + "@octokit/types": "^8.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "@octokit/rest": { + "version": "19.0.5", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-19.0.5.tgz", + "integrity": "sha512-+4qdrUFq2lk7Va+Qff3ofREQWGBeoTKNqlJO+FGjFP35ZahP+nBenhZiGdu8USSgmq4Ky3IJ/i4u0xbLqHaeow==", + "requires": { + "@octokit/core": "^4.1.0", + "@octokit/plugin-paginate-rest": "^5.0.0", + "@octokit/plugin-request-log": "^1.0.4", + "@octokit/plugin-rest-endpoint-methods": "^6.7.0" + } + }, + "@octokit/types": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-8.0.0.tgz", + "integrity": "sha512-65/TPpOJP1i3K4lBJMnWqPUJ6zuOtzhtagDvydAWbEXpbFYA0oMKKyLb95NFZZP0lSh/4b6K+DQlzvYQJQQePg==", + "requires": { + "@octokit/openapi-types": "^14.0.0" + } + }, "@posthog/plugin-scaffold": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/@posthog/plugin-scaffold/-/plugin-scaffold-1.3.4.tgz", @@ -13488,6 +13829,11 @@ "@types/node": "*" } }, + "@types/libsodium-wrappers": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz", + "integrity": "sha512-BqI9B92u+cM3ccp8mpHf+HzJ8fBlRwdmyd6+fz3p99m3V6ifT5O3zmOMi612PGkpeFeG/G6loxUnzlDNhfjPSA==" + }, "@types/mime": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", @@ -13911,6 +14257,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" + }, "bigint-conversion": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/bigint-conversion/-/bigint-conversion-2.2.2.tgz", @@ -14331,6 +14682,11 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, + "deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" + }, "destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -15263,6 +15619,11 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -15909,6 +16270,19 @@ "type-check": "~0.4.0" } }, + "libsodium": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.10.tgz", + "integrity": "sha512-eY+z7hDrDKxkAK+QKZVNv92A5KYkxfvIshtBJkmg5TSiCnYqZP3i9OO9whE79Pwgm4jGaoHgkM4ao/b9Cyu4zQ==" + }, + "libsodium-wrappers": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz", + "integrity": "sha512-pO3F1Q9NPLB/MWIhehim42b/Fwb30JNScCNh8TcQ/kIc+qGLQch8ag8wb0keK3EP5kbGakk1H8Wwo7v+36rNQg==", + "requires": { + "libsodium": "^0.7.0" + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -16211,6 +16585,35 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -19033,6 +19436,11 @@ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", "dev": true }, + "universal-user-agent": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", + "integrity": "sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 32ca68b19..a2c583048 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,9 +1,11 @@ { "dependencies": { "@godaddy/terminus": "^4.11.2", + "@octokit/rest": "^19.0.5", "@sentry/node": "^7.14.0", "@sentry/tracing": "^7.19.0", "@types/crypto-js": "^4.1.1", + "@types/libsodium-wrappers": "^0.7.10", "axios": "^1.1.3", "bigint-conversion": "^2.2.2", "cookie-parser": "^1.4.6", @@ -17,6 +19,7 @@ "helmet": "^5.1.1", "jsonwebtoken": "^8.5.1", "jsrp": "^0.2.4", + "libsodium-wrappers": "^0.7.10", "mongoose": "^6.7.2", "nodemailer": "^6.8.0", "posthog-node": "^2.1.0", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ea5f653a3..833fd5642 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -13,8 +13,13 @@ const NODE_ENV = process.env.NODE_ENV! || 'production'; const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_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_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 POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com'; const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY! || @@ -52,8 +57,10 @@ export { OAUTH_CLIENT_SECRET_HEROKU, CLIENT_ID_VERCEL, CLIENT_ID_NETLIFY, + CLIENT_ID_GITHUB, CLIENT_SECRET_VERCEL, CLIENT_SECRET_NETLIFY, + CLIENT_SECRET_GITHUB, POSTHOG_HOST, POSTHOG_PROJECT_API_KEY, PRIVATE_KEY, diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index 70680ef7d..c4e53c1f8 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,15 +1,16 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; +import { Octokit } from '@octokit/rest'; +import { IIntegrationAuth } from '../models'; import { - IIntegrationAuth -} from '../models'; -import { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_GITHUB_API_URL } from '../variables'; /** @@ -21,47 +22,51 @@ import { * @returns {String} apps.name - name of integration app */ const getApps = async ({ - integrationAuth, - accessToken + integrationAuth, + accessToken }: { - integrationAuth: IIntegrationAuth; - accessToken: string; + integrationAuth: IIntegrationAuth; + accessToken: string; }) => { - - interface App { - name: string; - siteId?: string; - } + interface App { + name: string; + siteId?: string; + } - let apps: App[]; // TODO: add type and define payloads for apps - try { - switch (integrationAuth.integration) { - case INTEGRATION_HEROKU: - apps = await getAppsHeroku({ - accessToken - }); - break; - case INTEGRATION_VERCEL: - apps = await getAppsVercel({ - accessToken - }); - break; - case INTEGRATION_NETLIFY: - apps = await getAppsNetlify({ - integrationAuth, - accessToken - }); - break; - } - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get integration apps'); + let apps: App[]; // TODO: add type and define payloads for apps + try { + switch (integrationAuth.integration) { + case INTEGRATION_HEROKU: + apps = await getAppsHeroku({ + accessToken + }); + break; + case INTEGRATION_VERCEL: + apps = await getAppsVercel({ + accessToken + }); + break; + case INTEGRATION_NETLIFY: + apps = await getAppsNetlify({ + integrationAuth, + accessToken + }); + break; + case INTEGRATION_GITHUB: + apps = await getAppsGithub({ + integrationAuth, + accessToken + }); + break; } - - return apps; -} + } 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 @@ -70,31 +75,29 @@ const getApps = async ({ * @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_API_URL}/apps`, { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: `Bearer ${accessToken}` - } - })).data; - - apps = res.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; -} +const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { + let apps; + try { + const res = ( + await axios.get(`${INTEGRATION_HEROKU_API_URL}/apps`, { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + }) + ).data; + + apps = res.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; +}; /** * Return list of names of apps for Vercel integration @@ -103,30 +106,28 @@ const getAppsHeroku = async ({ * @returns {Object[]} apps - names of Vercel apps * @returns {String} apps.name - name of Vercel app */ -const getAppsVercel = async ({ - accessToken -}: { - accessToken: string; -}) => { - let apps; - try { - const res = (await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { - headers: { - Authorization: `Bearer ${accessToken}` - } - })).data; - - apps = res.projects.map((a: any) => ({ - name: a.name - })); - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get Vercel integration apps'); - } - - return apps; -} +const getAppsVercel = async ({ accessToken }: { accessToken: string }) => { + let apps; + try { + const res = ( + await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + ).data; + + apps = res.projects.map((a: any) => ({ + name: a.name + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Vercel integration apps'); + } + + return apps; +}; /** * Return list of names of sites for Netlify integration @@ -136,34 +137,69 @@ const getAppsVercel = async ({ * @returns {String} apps.name - name of Netlify site */ const getAppsNetlify = async ({ - integrationAuth, - accessToken + integrationAuth, + accessToken }: { - integrationAuth: IIntegrationAuth; - accessToken: string; + integrationAuth: IIntegrationAuth; + accessToken: string; }) => { - let apps; - try { - const res = (await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, { - headers: { - Authorization: `Bearer ${accessToken}` - } - })).data; - - apps = res.map((a: any) => ({ - name: a.name, - siteId: a.site_id - })); - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get Netlify integration apps'); - } - - return apps; -} + let apps; + try { + const res = ( + await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }) + ).data; -export { - getApps -} \ No newline at end of file + apps = res.map((a: any) => ({ + name: a.name, + siteId: a.site_id + })); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Netlify integration apps'); + } + + return apps; +}; + +/** + * Return list of names of repositories for Github integration + * @param {Object} obj + * @param {String} obj.accessToken - access token for Netlify API + * @returns {Object[]} apps - names of Netlify sites + * @returns {String} apps.name - name of Netlify site + */ +const getAppsGithub = async ({ + integrationAuth, + accessToken +}: { + integrationAuth: IIntegrationAuth; + accessToken: string; +}) => { + let apps; + try { + const octokit = new Octokit({ + auth: accessToken + }); + + 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; + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get Github repos'); + } + + return apps; +}; + +export { getApps }; diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 5b7bc71ae..0ddc2d20c 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -1,46 +1,57 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - ACTION_PUSH_TO_HEROKU + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_GITHUB_TOKEN_URL, + ACTION_PUSH_TO_HEROKU } from '../variables'; -import { - SITE_URL, - OAUTH_CLIENT_SECRET_HEROKU, - CLIENT_ID_VERCEL, - CLIENT_ID_NETLIFY, - CLIENT_SECRET_VERCEL, - CLIENT_SECRET_NETLIFY +import { + SITE_URL, + OAUTH_CLIENT_SECRET_HEROKU, + CLIENT_ID_VERCEL, + CLIENT_ID_NETLIFY, + CLIENT_ID_GITHUB, + CLIENT_SECRET_VERCEL, + CLIENT_SECRET_NETLIFY, + CLIENT_SECRET_GITHUB } from '../config'; +import { user } from '../routes'; interface ExchangeCodeHerokuResponse { - token_type: string; - access_token: string; - expires_in: number; - refresh_token: string; - user_id: string; - session_nonce?: string; + token_type: string; + access_token: string; + expires_in: number; + refresh_token: string; + user_id: string; + session_nonce?: string; } interface ExchangeCodeVercelResponse { - token_type: string; - access_token: string; - installation_id: string; - user_id: string; - team_id?: string; + token_type: string; + access_token: string; + installation_id: string; + user_id: string; + team_id?: string; } interface ExchangeCodeNetlifyResponse { - access_token: string; - token_type: string; - refresh_token: string; - scope: string; - created_at: number; + access_token: string; + token_type: string; + refresh_token: string; + scope: string; + created_at: number; +} + +interface ExchangeCodeGithubResponse { + access_token: string; + scope: string; + token_type: string; } /** @@ -56,40 +67,45 @@ interface ExchangeCodeNetlifyResponse { * @returns {String} obj.action - integration action for bot sequence */ const exchangeCode = async ({ - integration, - code -}: { - integration: string; - code: string; + integration, + code +}: { + integration: string; + code: string; }) => { - let obj = {} as any; - - try { - switch (integration) { - case INTEGRATION_HEROKU: - obj = await exchangeCodeHeroku({ - code - }); - break; - case INTEGRATION_VERCEL: - obj = await exchangeCodeVercel({ - code - }); - break; - case INTEGRATION_NETLIFY: - obj = await exchangeCodeNetlify({ - code - }); - break; - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange'); + let obj = {} as any; + + try { + switch (integration) { + case INTEGRATION_HEROKU: + obj = await exchangeCodeHeroku({ + code + }); + break; + case INTEGRATION_VERCEL: + obj = await exchangeCodeVercel({ + code + }); + break; + case INTEGRATION_NETLIFY: + obj = await exchangeCodeNetlify({ + code + }); + break; + case INTEGRATION_GITHUB: + obj = await exchangeCodeGithub({ + code + }); + break; } - - return obj; -} + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange'); + } + + return obj; +}; /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku @@ -101,38 +117,34 @@ const exchangeCode = async ({ * @returns {String} obj2.refreshToken - refresh token for Heroku API * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ -const exchangeCodeHeroku = async ({ - code -}: { - code: string; -}) => { - let res: ExchangeCodeHerokuResponse; - let 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; + 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 + }; +}; /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel @@ -144,35 +156,33 @@ const exchangeCodeHeroku = async ({ * @returns {String} obj2.refreshToken - refresh token for Heroku API * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ -const exchangeCodeVercel = async ({ - code -}: { - code: string; -}) => { - let res: ExchangeCodeVercelResponse; - try { - res = (await axios.post( - INTEGRATION_VERCEL_TOKEN_URL, - new URLSearchParams({ - code: code, - client_id: CLIENT_ID_VERCEL, - client_secret: CLIENT_SECRET_VERCEL, - redirect_uri: `${SITE_URL}/vercel` - } as any) - )).data; - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Vercel'); - } - - return ({ - accessToken: res.access_token, - refreshToken: null, - accessExpiresAt: null, - teamId: res.team_id - }); -} +const exchangeCodeVercel = async ({ code }: { code: string }) => { + let res: ExchangeCodeVercelResponse; + try { + res = ( + await axios.post( + INTEGRATION_VERCEL_TOKEN_URL, + new URLSearchParams({ + code: code, + client_id: CLIENT_ID_VERCEL, + client_secret: CLIENT_SECRET_VERCEL, + redirect_uri: `${SITE_URL}/vercel` + } as any) + ) + ).data; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Vercel'); + } + + return { + accessToken: res.access_token, + refreshToken: null, + accessExpiresAt: null, + teamId: res.team_id + }; +}; /** * Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel @@ -184,58 +194,98 @@ const exchangeCodeVercel = async ({ * @returns {String} obj2.refreshToken - refresh token for Heroku API * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ -const exchangeCodeNetlify = async ({ - code -}: { - code: string; -}) => { - let res: ExchangeCodeNetlifyResponse; - let accountId; - try { - res = (await axios.post( - INTEGRATION_NETLIFY_TOKEN_URL, - new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - client_id: CLIENT_ID_NETLIFY, - client_secret: CLIENT_SECRET_NETLIFY, - redirect_uri: `${SITE_URL}/netlify` - } as any) - )).data; +const exchangeCodeNetlify = async ({ code }: { code: string }) => { + let res: ExchangeCodeNetlifyResponse; + let accountId; + try { + res = ( + await axios.post( + INTEGRATION_NETLIFY_TOKEN_URL, + new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: CLIENT_ID_NETLIFY, + client_secret: CLIENT_SECRET_NETLIFY, + redirect_uri: `${SITE_URL}/netlify` + } as any) + ) + ).data; - const res2 = await axios.get( - 'https://api.netlify.com/api/v1/sites', - { - headers: { - Authorization: `Bearer ${res.access_token}` - } - } - ); - - const res3 = (await axios.get( - 'https://api.netlify.com/api/v1/accounts', - { - headers: { - Authorization: `Bearer ${res.access_token}` - } - } - )).data; - - accountId = res3[0].id; - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed OAuth2 code-token exchange with Netlify'); - } - - return ({ - accessToken: res.access_token, - refreshToken: res.refresh_token, - accountId + const res2 = await axios.get('https://api.netlify.com/api/v1/sites', { + headers: { + Authorization: `Bearer ${res.access_token}` + } }); -} -export { - exchangeCode -} \ No newline at end of file + const res3 = ( + await axios.get('https://api.netlify.com/api/v1/accounts', { + headers: { + Authorization: `Bearer ${res.access_token}` + } + }) + ).data; + + accountId = res3[0].id; + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed OAuth2 code-token exchange with Netlify'); + } + + return { + accessToken: res.access_token, + refreshToken: res.refresh_token, + accountId + }; +}; + +/** + * Return [accessToken], [accessExpiresAt], and [refreshToken] for Github + * code-token exchange + * @param {Object} obj1 + * @param {Object} obj1.code - code for code-token exchange + * @returns {Object} obj2 + * @returns {String} obj2.accessToken - access token for Github API + * @returns {String} obj2.refreshToken - refresh token for Github API + * @returns {Date} obj2.accessExpiresAt - date of expiration for access token + */ +const exchangeCodeGithub = async ({ code }: { code: string }) => { + let res: ExchangeCodeGithubResponse; + let res2; + try { + res = ( + await axios.get(INTEGRATION_GITHUB_TOKEN_URL, { + params: { + client_id: CLIENT_ID_GITHUB, + client_secret: CLIENT_SECRET_GITHUB, + code: code, + redirect_uri: `${SITE_URL}/github` + } + }) + ).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 + }; +}; + +export { exchangeCode }; diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index b19a6a663..cbe7726ea 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,78 +1,72 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; import { INTEGRATION_HEROKU } from '../variables'; -import { - OAUTH_CLIENT_SECRET_HEROKU -} from '../config'; -import { - INTEGRATION_HEROKU_TOKEN_URL -} from '../variables'; +import { OAUTH_CLIENT_SECRET_HEROKU } from '../config'; +import { INTEGRATION_HEROKU_TOKEN_URL } from '../variables'; /** * Return new access token by exchanging refresh token [refreshToken] for integration * named [integration] * @param {Object} obj * @param {String} obj.integration - name of integration - * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku + * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku */ const exchangeRefresh = async ({ - integration, - refreshToken + integration, + refreshToken }: { - integration: string; - refreshToken: string; + integration: string; + refreshToken: string; }) => { - let accessToken; - try { - switch (integration) { - case INTEGRATION_HEROKU: - accessToken = await exchangeRefreshHeroku({ - refreshToken - }); - break; - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to get new OAuth2 access token'); + let accessToken; + try { + switch (integration) { + case INTEGRATION_HEROKU: + accessToken = await exchangeRefreshHeroku({ + refreshToken + }); + break; } - - return accessToken; -} + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get new OAuth2 access token'); + } + + return accessToken; +}; /** * Return new access token by exchanging refresh token [refreshToken] for the * Heroku integration * @param {Object} obj * @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku - * @returns + * @returns */ const exchangeRefreshHeroku = async ({ - refreshToken + refreshToken }: { - refreshToken: string; + refreshToken: string; }) => { - 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( + INTEGRATION_HEROKU_TOKEN_URL, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_secret: OAUTH_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'); - } - - return accessToken; -} + 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'); + } -export { - exchangeRefresh -} \ No newline at end of file + return accessToken; +}; + +export { exchangeRefresh }; diff --git a/backend/src/integrations/revoke.ts b/backend/src/integrations/revoke.ts index 833e6c88a..483486343 100644 --- a/backend/src/integrations/revoke.ts +++ b/backend/src/integrations/revoke.ts @@ -1,50 +1,47 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; +import { IIntegrationAuth, IntegrationAuth, Integration } from '../models'; import { - IIntegrationAuth, - IntegrationAuth, - Integration -} from '../models'; -import { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB } from '../variables'; const revokeAccess = async ({ - integrationAuth, - accessToken + integrationAuth, + accessToken }: { - integrationAuth: IIntegrationAuth, - accessToken: String + integrationAuth: IIntegrationAuth; + accessToken: string; }) => { - try { - // add any integration-specific revocation logic - switch (integrationAuth.integration) { - case INTEGRATION_HEROKU: - break; - case INTEGRATION_VERCEL: - break; - case INTEGRATION_NETLIFY: - break; - } - - const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ - _id: integrationAuth._id - }); - - if (deletedIntegrationAuth) { - await Integration.deleteMany({ - integrationAuth: deletedIntegrationAuth._id - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to delete integration authorization'); + try { + // add any integration-specific revocation logic + switch (integrationAuth.integration) { + case INTEGRATION_HEROKU: + break; + case INTEGRATION_VERCEL: + break; + case INTEGRATION_NETLIFY: + break; + case INTEGRATION_GITHUB: + break; } -} -export { - revokeAccess -} \ No newline at end of file + const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({ + _id: integrationAuth._id + }); + + if (deletedIntegrationAuth) { + await Integration.deleteMany({ + integrationAuth: deletedIntegrationAuth._id + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to delete integration authorization'); + } +}; + +export { revokeAccess }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 3cef519cc..363e96f34 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,16 +1,19 @@ import axios from 'axios'; import * as Sentry from '@sentry/node'; +import { Octokit } from '@octokit/rest'; +import * as sodium from 'libsodium-wrappers'; +import { IIntegration, IIntegrationAuth } from '../models'; import { - IIntegration, IIntegrationAuth -} from '../models'; -import { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_GITHUB_API_URL } from '../variables'; +import { access, appendFile } from 'fs'; // TODO: need a helper function in the future to handle integration // envar priorities (i.e. prioritize secrets within integration or those on Infisical) @@ -26,47 +29,55 @@ import { * @param {String} obj.accessToken - access token for integration */ const syncSecrets = async ({ - integration, - integrationAuth, - secrets, - accessToken, + integration, + integrationAuth, + secrets, + accessToken }: { - integration: IIntegration; - integrationAuth: IIntegrationAuth; - secrets: any; - accessToken: string; + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; }) => { - try { - switch (integration.integration) { - case INTEGRATION_HEROKU: - await syncSecretsHeroku({ - integration, - secrets, - accessToken - }); - break; - case INTEGRATION_VERCEL: - await syncSecretsVercel({ - integration, - secrets, - accessToken - }); - break; - case INTEGRATION_NETLIFY: - await syncSecretsNetlify({ - integration, - integrationAuth, - secrets, - accessToken - }); - break; - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to integration'); + try { + switch (integration.integration) { + case INTEGRATION_HEROKU: + await syncSecretsHeroku({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_VERCEL: + await syncSecretsVercel({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_NETLIFY: + await syncSecretsNetlify({ + integration, + integrationAuth, + secrets, + accessToken + }); + break; + case INTEGRATION_GITHUB: + await syncSecretsNetlify({ + integration, + integrationAuth, + 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] @@ -75,47 +86,49 @@ const syncSecrets = async ({ * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) */ const syncSecretsHeroku = async ({ - integration, - secrets, - accessToken + integration, + secrets, + accessToken }: { - integration: IIntegration, - secrets: any; - accessToken: string; + integration: IIntegration; + secrets: any; + accessToken: string; }) => { - try { - const herokuSecrets = (await axios.get( - `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, - { - headers: { - Accept: 'application/vnd.heroku+json; version=3', - Authorization: `Bearer ${accessToken}` - } - } - )).data; - - Object.keys(herokuSecrets).forEach(key => { - if (!(key in secrets)) { - secrets[key] = null; - } - }); + try { + const herokuSecrets = ( + await axios.get( + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, + { + headers: { + Accept: 'application/vnd.heroku+json; version=3', + Authorization: `Bearer ${accessToken}` + } + } + ) + ).data; - await axios.patch( - `${INTEGRATION_HEROKU_API_URL}/apps/${integration.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'); - } -} + Object.keys(herokuSecrets).forEach((key) => { + if (!(key in secrets)) { + secrets[key] = null; + } + }); + + await axios.patch( + `${INTEGRATION_HEROKU_API_URL}/apps/${integration.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'); + } +}; /** * Sync/push [secrets] to Heroku [app] @@ -124,149 +137,157 @@ const syncSecretsHeroku = async ({ * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) */ const syncSecretsVercel = async ({ - integration, - secrets, - accessToken + integration, + secrets, + accessToken }: { - integration: IIntegration, - secrets: any; - accessToken: string; + integration: IIntegration; + secrets: any; + accessToken: string; }) => { - - interface VercelSecret { - id?: string; - type: string; - key: string; - value: string; - target: string[]; - } - - try { - // Get all (decrypted) secrets back from Vercel in - // decrypted format - const params = new URLSearchParams({ - decrypt: "true" - }); - - const res = (await Promise.all((await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, + interface VercelSecret { + id?: string; + type: string; + key: string; + value: string; + target: string[]; + } + + try { + // Get all (decrypted) secrets back from Vercel in + // decrypted format + const params = new URLSearchParams({ + decrypt: 'true' + }); + + const res = ( + await Promise.all( + ( + await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, { - params, - headers: { - Authorization: `Bearer ${accessToken}` - } + params, + headers: { + Authorization: `Bearer ${accessToken}` + } } - )) - .data - .envs - .filter((secret: VercelSecret) => secret.target.includes(integration.target)) - .map(async (secret: VercelSecret) => (await axios.get( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, - { + ) + ).data.envs + .filter((secret: VercelSecret) => + secret.target.includes(integration.target) + ) + .map( + async (secret: VercelSecret) => + ( + await axios.get( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + { headers: { - Authorization: `Bearer ${accessToken}` + Authorization: `Bearer ${accessToken}` } - - } - )).data) - )).reduce((obj: any, secret: any) => ({ - ...obj, - [secret.key]: secret - }), {}); - - let updateSecrets: VercelSecret[] = []; - let deleteSecrets: VercelSecret[] = []; - let newSecrets: VercelSecret[] = []; + } + ) + ).data + ) + ) + ).reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret + }), + {} + ); - // Identify secrets to create - Object.keys(secrets).map((key) => { - if (!(key in res)) { - // case: secret has been created - newSecrets.push({ - key: key, - value: secrets[key], - type: 'encrypted', - target: [integration.target] - }); - } + const updateSecrets: VercelSecret[] = []; + const deleteSecrets: VercelSecret[] = []; + const newSecrets: VercelSecret[] = []; + + // Identify secrets to create + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: secret has been created + newSecrets.push({ + key: key, + value: secrets[key], + type: 'encrypted', + target: [integration.target] }); - - // Identify secrets to update and delete - Object.keys(res).map((key) => { - if (key in secrets) { - if (res[key].value !== secrets[key]) { - // case: secret value has changed - updateSecrets.push({ - id: res[key].id, - key: key, - value: secrets[key], - type: 'encrypted', - target: [integration.target] - }); - } - } else { - // case: secret has been deleted - deleteSecrets.push({ - id: res[key].id, - key: key, - value: res[key].value, - type: 'encrypted', - target: [integration.target], - }); - } + } + }); + + // Identify secrets to update and delete + Object.keys(res).map((key) => { + if (key in secrets) { + if (res[key].value !== secrets[key]) { + // case: secret value has changed + updateSecrets.push({ + id: res[key].id, + key: key, + value: secrets[key], + type: 'encrypted', + target: [integration.target] + }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + id: res[key].id, + key: key, + value: res[key].value, + type: 'encrypted', + target: [integration.target] }); + } + }); - // Sync/push new secrets - if (newSecrets.length > 0) { - await axios.post( - `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, - newSecrets, - { - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, + newSecrets, + { + headers: { + Authorization: `Bearer ${accessToken}` + } } - - // Sync/push updated secrets - if (updateSecrets.length > 0) { - updateSecrets.forEach(async (secret: VercelSecret) => { - const { - id, - ...updatedSecret - } = secret; - await axios.patch( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, - updatedSecret, - { - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - - // Delete secrets - if (deleteSecrets.length > 0) { - deleteSecrets.forEach(async (secret: VercelSecret) => { - await axios.delete( - `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, - { - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to Vercel'); + ); } -} + + // Sync/push updated secrets + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: VercelSecret) => { + const { id, ...updatedSecret } = secret; + await axios.patch( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + updatedSecret, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + // Delete secrets + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret: VercelSecret) => { + await axios.delete( + `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Vercel'); + } +}; /** * Sync/push [secrets] to Netlify site [app] @@ -276,154 +297,266 @@ const syncSecretsVercel = async ({ * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) */ const syncSecretsNetlify = async ({ - integration, - integrationAuth, - secrets, - accessToken + integration, + integrationAuth, + secrets, + accessToken }: { - integration: IIntegration; - integrationAuth: IIntegrationAuth; - secrets: any; - accessToken: string; + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; }) => { - try { - const getParams = new URLSearchParams({ - context_name: integration.context, - site_id: integration.siteId - }); - - const res = (await axios.get( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, - { - params: getParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - )) - .data - .reduce((obj: any, secret: any) => ({ - ...obj, - [secret.key]: secret.values[0].value - }), {}); - - interface UpdateNetlifySecret { - key: string; - context: string; - value: string; - } - - interface DeleteNetlifySecret { - key: string; - } - - interface NewNetlifySecretValue { - value: string; - context: string; - } - - interface NewNetlifySecret { - key: string; - values: NewNetlifySecretValue[]; - } - - let updateSecrets: UpdateNetlifySecret[] = []; - let deleteSecrets: DeleteNetlifySecret[] = []; - let newSecrets: NewNetlifySecret[] = []; + try { + const getParams = new URLSearchParams({ + context_name: integration.context, + site_id: integration.siteId + }); - // Identify secrets to create - Object.keys(secrets).map((key) => { - if (!(key in res)) { - // case: secret has been created - newSecrets.push({ - key: key, - values: [{ - value: secrets[key], // include id? - context: integration.context - }] - }); - } - }); - - // Identify secrets to update and delete - Object.keys(res).map((key) => { - if (key in secrets) { - if (res[key] !== secrets[key]) { - // case: secret value has changed - updateSecrets.push({ - key: key, - context: integration.context, - value: secrets[key] - }); - } - } else { - // case: secret has been deleted - deleteSecrets.push({ - key - }); - } - }); - - const syncParams = new URLSearchParams({ - site_id: integration.siteId - }); - - // Sync/push new secrets - if (newSecrets.length > 0) { - await axios.post( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, - newSecrets, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); + const res = ( + await axios.get( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + { + params: getParams, + headers: { + Authorization: `Bearer ${accessToken}` + } } + ) + ).data.reduce( + (obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret.values[0].value + }), + {} + ); - // Sync/push updated secrets - if (updateSecrets.length > 0) { - - updateSecrets.forEach(async (secret: UpdateNetlifySecret) => { - await axios.patch( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, - { - context: secret.context, - value: secret.value - }, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - - // Delete secrets - if (deleteSecrets.length > 0) { - deleteSecrets.forEach(async (secret: DeleteNetlifySecret) => { - await axios.delete( - `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, - { - params: syncParams, - headers: { - Authorization: `Bearer ${accessToken}` - } - } - ); - }); - } - - } catch (err) { - Sentry.setUser(null); - Sentry.captureException(err); - throw new Error('Failed to sync secrets to Heroku'); + interface UpdateNetlifySecret { + key: string; + context: string; + value: string; } -} -export { - syncSecrets -} \ No newline at end of file + interface DeleteNetlifySecret { + key: string; + } + + interface NewNetlifySecretValue { + value: string; + context: string; + } + + interface NewNetlifySecret { + key: string; + values: NewNetlifySecretValue[]; + } + + const updateSecrets: UpdateNetlifySecret[] = []; + const deleteSecrets: DeleteNetlifySecret[] = []; + const newSecrets: NewNetlifySecret[] = []; + + // Identify secrets to create + Object.keys(secrets).map((key) => { + if (!(key in res)) { + // case: secret has been created + newSecrets.push({ + key: key, + values: [ + { + value: secrets[key], // include id? + context: integration.context + } + ] + }); + } + }); + + // Identify secrets to update and delete + Object.keys(res).map((key) => { + if (key in secrets) { + if (res[key] !== secrets[key]) { + // case: secret value has changed + updateSecrets.push({ + key: key, + context: integration.context, + value: secrets[key] + }); + } + } else { + // case: secret has been deleted + deleteSecrets.push({ + key + }); + } + }); + + const syncParams = new URLSearchParams({ + site_id: integration.siteId + }); + + // Sync/push new secrets + if (newSecrets.length > 0) { + await axios.post( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, + newSecrets, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + } + + // Sync/push updated secrets + if (updateSecrets.length > 0) { + updateSecrets.forEach(async (secret: UpdateNetlifySecret) => { + await axios.patch( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + context: secret.context, + value: secret.value + }, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + + // Delete secrets + if (deleteSecrets.length > 0) { + deleteSecrets.forEach(async (secret: DeleteNetlifySecret) => { + await axios.delete( + `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, + { + params: syncParams, + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + }); + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Heroku'); + } +}; + +/** + * Sync/push [secrets] to Github site [app] + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {IIntegrationAuth} obj.integrationAuth - integration auth details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + */ +const syncSecretsGithub = async ({ + integration, + integrationAuth, + secrets, + accessToken +}: { + integration: IIntegration; + integrationAuth: IIntegrationAuth; + secrets: any; + accessToken: string; +}) => { + try { + const deleteSecrets: Array = []; + + const octokit = new Octokit({ + auth: accessToken + }); + + const loggedInUser = await octokit.request('GET /user', {}); + // TODO: Check loggedInUser.login == repo owner + const repoPublicKey = await octokit.request( + 'GET /repos/{owner}/{repo}/actions/secrets/public-key', + { + owner: loggedInUser.login, + repo: integration.app + } + ).key; + + 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 /repos/{owner}/{repo}/actions/secrets', + { + owner: loggedInUser.name, + 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( + 'DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}', + { + owner: loggedInUser.login, + repo: integration.app, + secret_name: secrets[i] + } + ); + } + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to sync secrets to Github'); + } +}; + +export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index edbe0234e..6da699216 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -1,77 +1,83 @@ import { Schema, model, Types } from 'mongoose'; import { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB } from '../variables'; export interface IIntegration { - _id: Types.ObjectId; - workspace: Types.ObjectId; - environment: 'dev' | 'test' | 'staging' | 'prod'; - isActive: boolean; - app: string; - target: string; - context: string; - siteId: string; - integration: 'heroku' | 'vercel' | 'netlify'; - integrationAuth: Types.ObjectId; + _id: Types.ObjectId; + workspace: Types.ObjectId; + environment: 'dev' | 'test' | 'staging' | 'prod'; + isActive: boolean; + app: string; + target: string; + context: string; + siteId: string; + integration: 'heroku' | 'vercel' | 'netlify' | 'github'; + integrationAuth: Types.ObjectId; } const integrationSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace', - required: true - }, - environment: { - type: String, - enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], - required: true - }, - isActive: { - type: Boolean, - required: true - }, - app: { // name of app in provider - type: String, - default: null - }, - target: { // vercel-specific target (environment) - type: String, - default: null - }, - context: { // netlify-specific context (deploy) - type: String, - default: null - }, - siteId: { // netlify-specific site (app) id - type: String, - default: null - }, - integration: { - type: String, - enum: [ - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY - ], - required: true - }, - integrationAuth: { - type: Schema.Types.ObjectId, - ref: 'IntegrationAuth', - required: true - } - }, - { - timestamps: true - } + { + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + environment: { + type: String, + enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], + required: true + }, + isActive: { + type: Boolean, + required: true + }, + app: { + // name of app in provider + type: String, + default: null + }, + target: { + // vercel-specific target (environment) + type: String, + default: null + }, + context: { + // netlify-specific context (deploy) + type: String, + default: null + }, + siteId: { + // netlify-specific site (app) id + type: String, + default: null + }, + integration: { + type: String, + enum: [ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB + ], + required: true + }, + integrationAuth: { + type: Schema.Types.ObjectId, + ref: 'IntegrationAuth', + required: true + } + }, + { + timestamps: true + } ); const Integration = model('Integration', integrationSchema); diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index 0da3eb0d8..231416588 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -1,83 +1,87 @@ import { Schema, model, Types } from 'mongoose'; -import { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY +import { + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB } from '../variables'; export interface IIntegrationAuth { - _id: Types.ObjectId; - workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify'; - teamId: string; - accountId: string; - refreshCiphertext?: string; - refreshIV?: string; - refreshTag?: string; - accessCiphertext?: string; - accessIV?: string; - accessTag?: string; - accessExpiresAt?: Date; + _id: Types.ObjectId; + workspace: Types.ObjectId; + integration: 'heroku' | 'vercel' | 'netlify' | 'github'; + teamId: string; + accountId: string; + refreshCiphertext?: string; + refreshIV?: string; + refreshTag?: string; + accessCiphertext?: string; + accessIV?: string; + accessTag?: string; + accessExpiresAt?: Date; } const integrationAuthSchema = new Schema( - { - workspace: { - type: Schema.Types.ObjectId, - required: true - }, - integration: { - type: String, - enum: [ - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY - ], - required: true - }, - teamId: { // vercel-specific integration param - type: String - }, - accountId: { // netlify-specific integration param - type: String - }, - refreshCiphertext: { - type: String, - select: false - }, - refreshIV: { - type: String, - select: false - }, - refreshTag: { - type: String, - select: false - }, - accessCiphertext: { - type: String, - select: false - }, - accessIV: { - type: String, - select: false - }, - accessTag: { - type: String, - select: false - }, - accessExpiresAt: { - type: Date, - select: false - } - }, - { - timestamps: true - } + { + workspace: { + type: Schema.Types.ObjectId, + required: true + }, + integration: { + type: String, + enum: [ + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB + ], + required: true + }, + teamId: { + // vercel-specific integration param + type: String + }, + accountId: { + // netlify-specific integration param + type: String + }, + refreshCiphertext: { + type: String, + select: false + }, + refreshIV: { + type: String, + select: false + }, + refreshTag: { + type: String, + select: false + }, + accessCiphertext: { + type: String, + select: false + }, + accessIV: { + type: String, + select: false + }, + accessTag: { + type: String, + select: false + }, + accessExpiresAt: { + type: Date, + select: false + } + }, + { + timestamps: true + } ); const IntegrationAuth = model( - 'IntegrationAuth', - integrationAuthSchema + 'IntegrationAuth', + integrationAuthSchema ); export default IntegrationAuth; diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 362635806..0824b49bf 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -1,77 +1,72 @@ import { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET } from './environment'; import { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_GITHUB_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_GITHUB_API_URL } from './integration'; import { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - COMPLETED, - GRANTED + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED } from './organization'; -import { - SECRET_SHARED, - SECRET_PERSONAL -} from './secret'; -import { - PLAN_STARTER, - PLAN_PRO -} from './stripe'; -import { - EVENT_PUSH_SECRETS, - EVENT_PULL_SECRETS -} from './event'; -import { - ACTION_PUSH_TO_HEROKU -} from './action'; +import { SECRET_SHARED, SECRET_PERSONAL } from './secret'; +import { PLAN_STARTER, PLAN_PRO } from './stripe'; +import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from './event'; +import { ACTION_PUSH_TO_HEROKU } from './action'; export { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - COMPLETED, - GRANTED, - PLAN_STARTER, - PLAN_PRO, - SECRET_SHARED, - SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - EVENT_PUSH_SECRETS, - EVENT_PULL_SECRETS, - ACTION_PUSH_TO_HEROKU -}; \ No newline at end of file + OWNER, + ADMIN, + MEMBER, + INVITED, + ACCEPTED, + COMPLETED, + GRANTED, + PLAN_STARTER, + PLAN_PRO, + SECRET_SHARED, + SECRET_PERSONAL, + ENV_DEV, + ENV_TESTING, + ENV_STAGING, + ENV_PROD, + ENV_SET, + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_GITHUB_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_GITHUB_API_URL, + EVENT_PUSH_SECRETS, + EVENT_PULL_SECRETS, + ACTION_PUSH_TO_HEROKU +}; diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 1dd721a11..d5483904a 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -2,10 +2,12 @@ const INTEGRATION_HEROKU = 'heroku'; const INTEGRATION_VERCEL = 'vercel'; const INTEGRATION_NETLIFY = 'netlify'; +const INTEGRATION_GITHUB = 'github'; const INTEGRATION_SET = new Set([ - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB ]); // integration types @@ -13,24 +15,31 @@ const INTEGRATION_OAUTH2 = 'oauth2'; // integration oauth endpoints const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; -const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token'; +const INTEGRATION_VERCEL_TOKEN_URL = + 'https://api.vercel.com/v2/oauth/access_token'; const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token'; +const INTEGRATION_GITHUB_TOKEN_URL = + 'https://github.com/login/oauth/access_token'; // integration apps endpoints 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/'; export { - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, -} \ No newline at end of file + INTEGRATION_HEROKU, + INTEGRATION_VERCEL, + INTEGRATION_NETLIFY, + INTEGRATION_GITHUB, + INTEGRATION_SET, + INTEGRATION_OAUTH2, + INTEGRATION_HEROKU_TOKEN_URL, + INTEGRATION_VERCEL_TOKEN_URL, + INTEGRATION_NETLIFY_TOKEN_URL, + INTEGRATION_GITHUB_TOKEN_URL, + INTEGRATION_HEROKU_API_URL, + INTEGRATION_VERCEL_API_URL, + INTEGRATION_NETLIFY_API_URL, + INTEGRATION_GITHUB_API_URL +}; From 11e64febdb6f111b7fbe1c7d0101295585c31c85 Mon Sep 17 00:00:00 2001 From: George Gale Date: Mon, 19 Dec 2022 10:24:16 +0100 Subject: [PATCH 2/2] Added frontend for Github integration. --- frontend/pages/github.js | 37 +++++++++++++++++++++++++++++ frontend/pages/integrations/[id].js | 3 +++ 2 files changed, 40 insertions(+) create mode 100644 frontend/pages/github.js diff --git a/frontend/pages/github.js b/frontend/pages/github.js new file mode 100644 index 000000000..af2b2b087 --- /dev/null +++ b/frontend/pages/github.js @@ -0,0 +1,37 @@ +import React, { useEffect } from "react"; +import Head from "next/head"; +import { useRouter } from "next/router"; +const queryString = require("query-string"); +import AuthorizeIntegration from "./api/integrations/authorizeIntegration"; + +export default function Github() { + const router = useRouter(); + const parsedUrl = queryString.parse(router.asPath.split("?")[1]); + const code = parsedUrl.code; + const state = parsedUrl.state; + + /** + * Here we forward to the default workspace if a user opens this url + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(async () => { + try { + if (state === localStorage.getItem('latestCSRFToken')) { + localStorage.removeItem('latestCSRFToken'); + await AuthorizeIntegration({ + workspaceId: localStorage.getItem('projectData.id'), + code, + integration: "github", + }); + router.push("/integrations/" + localStorage.getItem("projectData.id")); + } + } catch (error) { + console.error('Github integration error: ', error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} + +Github.requireAuth = true; diff --git a/frontend/pages/integrations/[id].js b/frontend/pages/integrations/[id].js index 46aec2220..a5cb866be 100644 --- a/frontend/pages/integrations/[id].js +++ b/frontend/pages/integrations/[id].js @@ -134,6 +134,9 @@ export default function Integrations() { 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);