From 059c552307a0fe091a35caebc66e49a9cac6ad3f Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 10 Oct 2024 03:22:25 +0800 Subject: [PATCH] misc: initial setup for github integration with Github app auth --- .env.example | 3 + backend/src/lib/config/env.ts | 7 +- .../routes/v1/integration-auth-router.ts | 1 + .../integration-auth-service.ts | 15 ++- .../integration-auth-types.ts | 1 + .../integration-auth/integration-list.ts | 5 +- .../integration-auth/integration-token.ts | 77 ++++++++++- .../src/hooks/api/integrationAuth/queries.tsx | 3 + .../src/hooks/api/integrationAuth/types.ts | 3 + .../github/auth-mode-selection.tsx | 89 +++++++++++++ .../integrations/github/oauth2/callback.tsx | 9 +- .../integrations/select-integration-auth.tsx | 121 ++++++++++++++++++ .../IntegrationPage.utils.tsx | 2 +- 13 files changed, 323 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/integrations/github/auth-mode-selection.tsx create mode 100644 frontend/src/pages/integrations/select-integration-auth.tsx diff --git a/.env.example b/.env.example index be7e0a621..6c76a75fd 100644 --- a/.env.example +++ b/.env.example @@ -36,12 +36,15 @@ CLIENT_ID_HEROKU= CLIENT_ID_VERCEL= CLIENT_ID_NETLIFY= CLIENT_ID_GITHUB= +CLIENT_ID_GITHUB_APP= +CLIENT_SLUG_GITHUB= CLIENT_ID_GITLAB= CLIENT_ID_BITBUCKET= CLIENT_SECRET_HEROKU= CLIENT_SECRET_VERCEL= CLIENT_SECRET_NETLIFY= CLIENT_SECRET_GITHUB= +CLIENT_SECRET_GITHUB_APP= CLIENT_SECRET_GITLAB= CLIENT_SECRET_BITBUCKET= CLIENT_SLUG_VERCEL= diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index b047de755..a11762564 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -117,9 +117,14 @@ const envSchema = z // gcp secret manager CLIENT_ID_GCP_SECRET_MANAGER: zpStr(z.string().optional()), CLIENT_SECRET_GCP_SECRET_MANAGER: zpStr(z.string().optional()), - // github + // github oauth CLIENT_ID_GITHUB: zpStr(z.string().optional()), CLIENT_SECRET_GITHUB: zpStr(z.string().optional()), + CLIENT_SLUG_GITHUB: zpStr(z.string().optional()), + // github app + CLIENT_ID_GITHUB_APP: zpStr(z.string().optional()), + CLIENT_SECRET_GITHUB_APP: zpStr(z.string().optional()), + // azure CLIENT_ID_AZURE: zpStr(z.string().optional()), CLIENT_SECRET_AZURE: zpStr(z.string().optional()), diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 4baa39f76..1e6a88129 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -189,6 +189,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) workspaceId: z.string().trim(), code: z.string().trim(), integration: z.string().trim(), + installationId: z.string().trim().optional(), url: z.string().trim().url().optional() }), response: { diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index c3e0dfe06..83e880b3e 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -109,7 +109,8 @@ export const integrationAuthServiceFactory = ({ actorAuthMethod, integration, url, - code + code, + installationId }: TOauthExchangeDTO) => { if (!Object.values(Integrations).includes(integration as Integrations)) throw new BadRequestError({ message: "Invalid integration" }); @@ -123,7 +124,7 @@ export const integrationAuthServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); - const tokenExchange = await exchangeCode({ integration, code, url }); + const tokenExchange = await exchangeCode({ integration, code, url, installationId }); const updateDoc: TIntegrationAuthsInsert = { projectId, integration, @@ -141,6 +142,16 @@ export const integrationAuthServiceFactory = ({ updateDoc.metadata = { authMethod: "oauth2" }; + } else if (integration === Integrations.GITHUB && installationId) { + updateDoc.metadata = { + installationId, + installationName: tokenExchange.installationName, + authMethod: "app" + }; + } + + if (installationId && integration === Integrations.GITHUB) { + return integrationAuthDAL.create(updateDoc); } const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index af390297a..b8afe5ecb 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -9,6 +9,7 @@ export type TOauthExchangeDTO = { integration: string; code: string; url?: string; + installationId?: string; } & TProjectPermission; export type TSaveIntegrationAccessTokenDTO = { diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index ef6d0160e..8019ae9b8 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -96,7 +96,9 @@ export enum IntegrationUrls { GCP_SECRET_MANAGER_SERVICE_NAME = "secretmanager.googleapis.com", GCP_SECRET_MANAGER_URL = `https://${GCP_SECRET_MANAGER_SERVICE_NAME}`, GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com", - GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform" + GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform", + + GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations" } export const getIntegrationOptions = async () => { @@ -138,6 +140,7 @@ export const getIntegrationOptions = async () => { isAvailable: true, type: "oauth", clientId: appCfg.CLIENT_ID_GITHUB, + clientSlug: appCfg.CLIENT_SLUG_GITHUB, docsLink: "" }, { diff --git a/backend/src/services/integration-auth/integration-token.ts b/backend/src/services/integration-auth/integration-token.ts index ba26a3aaa..6daacd62a 100644 --- a/backend/src/services/integration-auth/integration-token.ts +++ b/backend/src/services/integration-auth/integration-token.ts @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { Integrations, IntegrationUrls } from "./integration-list"; @@ -234,12 +234,73 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => { }; }; -const exchangeCodeGithub = async ({ code }: { code: string }) => { +const exchangeCodeGithub = async ({ code, installationId }: { code: string; installationId?: string }) => { const appCfg = getConfig(); - if (!appCfg.CLIENT_ID_GITHUB || !appCfg.CLIENT_SECRET_GITHUB) { - throw new BadRequestError({ message: "Missing client id and client secret" }); + + if (!installationId && (!appCfg.CLIENT_ID_GITHUB || !appCfg.CLIENT_SECRET_GITHUB)) { + throw new InternalServerError({ message: "Missing client id and client secret" }); } + if (installationId && (!appCfg.CLIENT_ID_GITHUB_APP || !appCfg.CLIENT_SECRET_GITHUB_APP)) { + throw new InternalServerError({ + message: "Missing Github app client ID and client secret" + }); + } + + if (installationId) { + // handle app installations + const res = ( + await request.get(IntegrationUrls.GITHUB_TOKEN_URL, { + params: { + client_id: appCfg.CLIENT_ID_GITHUB_APP, + client_secret: appCfg.CLIENT_SECRET_GITHUB_APP, + code, + redirect_uri: `${appCfg.SITE_URL}/integrations/github/oauth2/callback` + }, + headers: { + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + // use access token to validate installation ID + const installationsRes = ( + await request.get<{ + installations: { + id: number; + account: { + login: string; + }; + }[]; + }>(IntegrationUrls.GITHUB_USER_INSTALLATIONS, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${res.access_token}`, + "Accept-Encoding": "application/json" + } + }) + ).data; + + const matchingInstallation = installationsRes.installations.find( + (installation) => installation.id === +installationId + ); + + if (!matchingInstallation) { + throw new ForbiddenRequestError({ + message: "User has no access to the provided installation" + }); + } + + return { + accessToken: "", + refreshToken: null, + accessExpiresAt: null, + installationName: matchingInstallation.account.login + }; + } + + // handle normal oauth const res = ( await request.get(IntegrationUrls.GITHUB_TOKEN_URL, { params: { @@ -346,6 +407,7 @@ type TExchangeReturn = { url?: string; teamId?: string; accountId?: string; + installationName?: string; }; /** @@ -355,11 +417,13 @@ type TExchangeReturn = { export const exchangeCode = async ({ integration, code, - url + url, + installationId }: { integration: string; code: string; url?: string; + installationId?: string; }): Promise => { switch (integration) { case Integrations.GCP_SECRET_MANAGER: @@ -384,7 +448,8 @@ export const exchangeCode = async ({ }); case Integrations.GITHUB: return exchangeCodeGithub({ - code + code, + installationId }); case Integrations.GITLAB: return exchangeCodeGitlab({ diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 914c3a71c..6688f9a99 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -777,11 +777,13 @@ export const useAuthorizeIntegration = () => { workspaceId, code, integration, + installationId, url }: { workspaceId: string; code: string; integration: string; + installationId?: string; url?: string; }) => { const { @@ -790,6 +792,7 @@ export const useAuthorizeIntegration = () => { workspaceId, code, integration, + installationId, url }); diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index b73528384..a32dab8ce 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -9,6 +9,9 @@ export type IntegrationAuth = { keyEncoding: string; url?: string; teamId?: string; + metadata: { + installationName?: string; + }; }; export type App = { diff --git a/frontend/src/pages/integrations/github/auth-mode-selection.tsx b/frontend/src/pages/integrations/github/auth-mode-selection.tsx new file mode 100644 index 000000000..5426cd562 --- /dev/null +++ b/frontend/src/pages/integrations/github/auth-mode-selection.tsx @@ -0,0 +1,89 @@ +import crypto from "crypto"; + +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, Card, CardTitle } from "@app/components/v2"; +import { useGetCloudIntegrations } from "@app/hooks/api"; + +export default function GithubIntegrationAuthModeSelectionPage() { + const router = useRouter(); + const { data: cloudIntegrations } = useGetCloudIntegrations(); + const githubIntegration = cloudIntegrations?.find((integration) => integration.slug === "github"); + + return ( +
+ + Select Github Integration Auth + + + + +
+
+ Github logo +
+ Github Integration + + +
+ + Docs + +
+
+ +
+
+
+ + +
+
+
+ ); +} + +GithubIntegrationAuthModeSelectionPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/github/oauth2/callback.tsx b/frontend/src/pages/integrations/github/oauth2/callback.tsx index c60338283..0ea8b20f7 100644 --- a/frontend/src/pages/integrations/github/oauth2/callback.tsx +++ b/frontend/src/pages/integrations/github/oauth2/callback.tsx @@ -8,18 +8,23 @@ export default function GitHubOAuth2CallbackPage() { const router = useRouter(); const { mutateAsync } = useAuthorizeIntegration(); - const { code, state } = queryString.parse(router.asPath.split("?")[1]); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { code, state, installation_id } = queryString.parse(router.asPath.split("?")[1]); useEffect(() => { (async () => { try { // validate state - if (state !== localStorage.getItem("latestCSRFToken")) return; + if (state !== localStorage.getItem("latestCSRFToken")) { + return; + } + localStorage.removeItem("latestCSRFToken"); const integrationAuth = await mutateAsync({ workspaceId: localStorage.getItem("projectData.id") as string, code: code as string, + installationId: installation_id as string, integration: "github" }); diff --git a/frontend/src/pages/integrations/select-integration-auth.tsx b/frontend/src/pages/integrations/select-integration-auth.tsx new file mode 100644 index 000000000..bb55fc01c --- /dev/null +++ b/frontend/src/pages/integrations/select-integration-auth.tsx @@ -0,0 +1,121 @@ +import crypto from "crypto"; + +import { useCallback } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import { useRouter } from "next/router"; + +import { Button, Card, CardTitle } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetCloudIntegrations, useGetWorkspaceAuthorizations } from "@app/hooks/api"; +import { IntegrationAuth } from "@app/hooks/api/types"; + +export default function SelectIntegrationAuthPage() { + const router = useRouter(); + const { data: cloudIntegrations } = useGetCloudIntegrations(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + + const integrationSlug = router.query.integrationSlug as string; + + const currentIntegration = cloudIntegrations?.find( + (integration) => integration.slug === integrationSlug + ); + + const { data: integrationAuths, isLoading: isLoadingIntegrationAuths } = + useGetWorkspaceAuthorizations( + workspaceId, + useCallback((data: IntegrationAuth[]) => { + const filteredIntegrationAuths = data.filter( + (integrationAuth) => integrationAuth.integration === integrationSlug + ); + + if (integrationSlug === "github") { + // for now, we only display the integration auths for Github apps + return filteredIntegrationAuths.filter((integrationAuth) => + Boolean(integrationAuth.metadata?.installationName) + ); + } + + return []; + }, []) + ); + + const logo = integrationSlug === "github" ? "/images/integrations/GitHub.png" : ""; + + const handleConnectionSelect = (integrationAuthId: string) => { + if (integrationSlug === "github") { + router.push(`/integrations/github/create?integrationAuthId=${integrationAuthId}`); + } + }; + + const handleNewConnection = () => { + const state = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", state); + + if (integrationSlug === "github") { + // for now we only handle Github apps + window.location.assign( + `https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}` + ); + } + }; + + return ( +
+ + Select Connection + + + + +
+
+ Integration logo +
+ Select Connection +
+
+
+ {!isLoadingIntegrationAuths && integrationAuths?.length + ? integrationAuths.map((integrationAuth) => { + let connectionName = ""; + + if (integrationAuth.integration === "github") { + connectionName = integrationAuth.metadata?.installationName || ""; + } + + return ( + + ); + }) + : undefined} + +
+
+
+ ); +} + +SelectIntegrationAuthPage.requireAuth = true; diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 18b9022ca..be7366259 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -60,7 +60,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`; break; case "github": - link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo,admin:org&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; + link = `${window.location.origin}/integrations/github/auth-mode-selection`; break; case "gitlab": link = `${window.location.origin}/integrations/gitlab/authorize`;