diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 8d7170b01..a32bedf72 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -10,11 +10,11 @@ import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, INTEGRATION_BITBUCKET_API_URL, + INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_NORTHFLANK_API_URL, INTEGRATION_RAILWAY_API_URL, INTEGRATION_SET, INTEGRATION_VERCEL_API_URL, - INTEGRATION_GCP_SECRET_MANAGER, getIntegrationOptions as getIntegrationOptionsFunc } from "../../variables"; import { exchangeRefresh } from "../../integrations"; @@ -51,7 +51,12 @@ export const getIntegrationOptions = async (req: Request, res: Response) => { * @returns */ export const oAuthExchange = async (req: Request, res: Response) => { - const { workspaceId, code, integration } = req.body; + const { + workspaceId, + code, + integration, + url + } = req.body; if (!INTEGRATION_SET.has(integration)) throw new Error("Failed to validate integration"); const environments = req.membership.workspace?.environments || []; @@ -63,7 +68,8 @@ export const oAuthExchange = async (req: Request, res: Response) => { workspaceId, integration, code, - environment: environments[0].slug + environment: environments[0].slug, + url }); await EEAuditLogService.createAuditLog( diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 6a948a039..485c19d7e 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -5,11 +5,11 @@ import { BotService } from "../services"; import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, + INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_NETLIFY, INTEGRATION_VERCEL, - INTEGRATION_GCP_SECRET_MANAGER, } from "../variables"; -import { BadRequestError, InternalServerError, UnauthorizedRequestError } from "../utils/errors"; +import { InternalServerError, UnauthorizedRequestError } from "../utils/errors"; import { IntegrationAuthMetadata } from "../models/integrationAuth/types"; interface Update { @@ -36,12 +36,14 @@ export const handleOAuthExchangeHelper = async ({ workspaceId, integration, code, - environment + environment, + url }: { workspaceId: string; integration: string; code: string; environment: string; + url?: string; }) => { const bot = await Bot.findOne({ workspace: workspaceId, @@ -53,7 +55,8 @@ export const handleOAuthExchangeHelper = async ({ // exchange code for access and refresh tokens const res = await exchangeCode({ integration, - code + code, + url }); const update: Update = { @@ -67,6 +70,7 @@ export const handleOAuthExchangeHelper = async ({ break; case INTEGRATION_NETLIFY: update.accountId = res.accountId; + break; case INTEGRATION_GCP_SECRET_MANAGER: update.metadata = { authMethod: "oauth2" diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index 641240470..a97a9db09 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -118,9 +118,11 @@ interface ExchangeCodeBitBucketResponse { const exchangeCode = async ({ integration, code, + url }: { integration: string; code: string; + url?: string; }) => { let obj = {} as any; @@ -158,6 +160,7 @@ const exchangeCode = async ({ case INTEGRATION_GITLAB: obj = await exchangeCodeGitlab({ code, + url }); break; case INTEGRATION_BITBUCKET: @@ -388,11 +391,17 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { * @returns {String} obj2.refreshToken - refresh token for Gitlab API * @returns {Date} obj2.accessExpiresAt - date of expiration for access token */ -const exchangeCodeGitlab = async ({ code }: { code: string }) => { +const exchangeCodeGitlab = async ({ + code, + url +}: { + code: string, + url?: string; +}) => { const accessExpiresAt = new Date(); const res: ExchangeCodeGitlabResponse = ( await standardRequest.post( - INTEGRATION_GITLAB_TOKEN_URL, + url ? `${url}/oauth/token` : INTEGRATION_GITLAB_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", code: code, diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 36cc4b930..dee4931c8 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -5,11 +5,11 @@ import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_BITBUCKET, INTEGRATION_BITBUCKET_TOKEN_URL, - INTEGRATION_GITLAB, - INTEGRATION_HEROKU, + INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE, INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_GCP_TOKEN_URL, - INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE + INTEGRATION_GITLAB, + INTEGRATION_HEROKU } from "../variables"; import { INTEGRATION_AZURE_TOKEN_URL, @@ -20,13 +20,13 @@ import { IntegrationService } from "../services"; import { getClientIdAzure, getClientIdBitBucket, + getClientIdGCPSecretManager, getClientIdGitLab, getClientSecretAzure, getClientSecretBitBucket, + getClientSecretGCPSecretManager, getClientSecretGitLab, getClientSecretHeroku, - getClientIdGCPSecretManager, - getClientSecretGCPSecretManager, getSiteURL, } from "../config"; @@ -112,6 +112,7 @@ const exchangeRefresh = async ({ break; case INTEGRATION_GITLAB: tokenDetails = await exchangeRefreshGitLab({ + integrationAuth, refreshToken, }); break; @@ -226,17 +227,21 @@ const exchangeRefreshHeroku = async ({ * @returns */ const exchangeRefreshGitLab = async ({ + integrationAuth, refreshToken, }: { + integrationAuth: IIntegrationAuth; refreshToken: string; }) => { const accessExpiresAt = new Date(); + const url = integrationAuth.url; + const { data, }: { data: RefreshTokenGitLabResponse; } = await standardRequest.post( - INTEGRATION_GITLAB_TOKEN_URL, + url ? `${url}/oauth/token` : INTEGRATION_GITLAB_TOKEN_URL, new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, @@ -329,17 +334,17 @@ const exchangeRefreshGCPSecretManager = async ({ exp: Math.floor(Date.now() / 1000) + 3600, }; - const token = jwt.sign(payload, serviceAccount.private_key, { algorithm: 'RS256' }); + const token = jwt.sign(payload, serviceAccount.private_key, { algorithm: "RS256" }); const { data }: { data: ServiceAccountAccessTokenGCPSecretManagerResponse } = await standardRequest.post( INTEGRATION_GCP_TOKEN_URL, new URLSearchParams({ - grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: token }).toString(), { headers: { - 'Content-Type': 'application/x-www-form-urlencoded' + "Content-Type": "application/x-www-form-urlencoded" } } ); diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 1f762e976..34cb67a22 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -1,4 +1,3 @@ -import jwt from "jsonwebtoken"; import { CreateSecretCommand, GetSecretValueCommand, @@ -29,8 +28,6 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_GCP_SECRET_MANAGER, INTEGRATION_GCP_SECRET_MANAGER_URL, - INTEGRATION_GCP_TOKEN_URL, - INTEGRATION_GCP_CLOUD_PLATFORM_SCOPE, INTEGRATION_GITHUB, INTEGRATION_GITLAB, INTEGRATION_GITLAB_API_URL, @@ -159,6 +156,7 @@ const syncSecrets = async ({ break; case INTEGRATION_GITLAB: await syncSecretsGitLab({ + integrationAuth, integration, secrets, accessToken @@ -1816,10 +1814,12 @@ const syncSecretsTravisCI = async ({ * @param {String} obj.accessToken - access token for GitLab integration */ const syncSecretsGitLab = async ({ + integrationAuth, integration, secrets, accessToken }: { + integrationAuth: IIntegrationAuth; integration: IIntegration; secrets: Record; accessToken: string; @@ -1829,9 +1829,10 @@ const syncSecretsGitLab = async ({ value: string; environment_scope: string; } + + const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL; const getAllEnvVariables = async (integrationAppId: string, accessToken: string) => { - const gitLabApiUrl = `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integrationAppId}/variables`; const headers = { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json", @@ -1839,7 +1840,7 @@ const syncSecretsGitLab = async ({ }; let allEnvVariables: GitLabSecret[] = []; - let url: string | null = `${gitLabApiUrl}?per_page=100`; + let url: string | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`; while (url) { const response: any = await standardRequest.get(url, { headers }); @@ -1867,7 +1868,7 @@ const syncSecretsGitLab = async ({ const existingSecret = getSecretsRes.find((s: any) => s.key == key); if (!existingSecret) { await standardRequest.post( - `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables`, + `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables`, { key: key, value: secrets[key].value, @@ -1888,7 +1889,7 @@ const syncSecretsGitLab = async ({ // update secret if (secrets[key].value !== existingSecret.value) { await standardRequest.put( - `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, + `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, { ...existingSecret, value: secrets[existingSecret.key].value @@ -1909,7 +1910,7 @@ const syncSecretsGitLab = async ({ for await (const sec of getSecretsRes) { if (!(sec.key in secrets)) { await standardRequest.delete( - `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${sec.key}?filter[environment_scope]=${integration.targetEnvironment}`, + `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables/${sec.key}?filter[environment_scope]=${integration.targetEnvironment}`, { headers: { Authorization: `Bearer ${accessToken}` diff --git a/backend/src/routes/v1/integrationAuth.ts b/backend/src/routes/v1/integrationAuth.ts index caa902072..73278a962 100644 --- a/backend/src/routes/v1/integrationAuth.ts +++ b/backend/src/routes/v1/integrationAuth.ts @@ -48,6 +48,7 @@ router.post( body("workspaceId").exists().trim().notEmpty(), body("code").exists().trim().notEmpty(), body("integration").exists().trim().notEmpty(), + body("url").optional().isString().trim(), validateRequest, integrationAuthController.oAuthExchange ); diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 9523058a0..06d0426f3 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -32,17 +32,20 @@ class IntegrationService { integration, code, environment, + url }: { workspaceId: string; integration: string; code: string; environment: string; + url?: string; }) { return await handleOAuthExchangeHelper({ workspaceId, integration, code, environment, + url }); } diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index aa1873853..84d22ce5b 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -367,16 +367,19 @@ export const useAuthorizeIntegration = () => { mutationFn: async ({ workspaceId, code, - integration + integration, + url }: { workspaceId: string; code: string; integration: string; + url?: string; }) => { const { data: { integrationAuth } } = await apiRequest.post("/api/v1/integration-auth/oauth-token", { workspaceId, code, - integration + integration, + url }); return integrationAuth; diff --git a/frontend/src/pages/integrations/gitlab/authorize.tsx b/frontend/src/pages/integrations/gitlab/authorize.tsx new file mode 100644 index 000000000..528324209 --- /dev/null +++ b/frontend/src/pages/integrations/gitlab/authorize.tsx @@ -0,0 +1,55 @@ +import crypto from "crypto"; + +import { useState } from "react"; +import { faGoogle } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useGetCloudIntegrations } from "@app/hooks/api"; + +import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2"; + +export default function GitLabAuthorizeIntegrationPage() { + const { data: cloudIntegrations } = useGetCloudIntegrations(); + + const [gitLabURL, setGitLabURL] = useState(""); + + const handleIntegrateWithOAuth = () => { + if (!cloudIntegrations) return; + const integrationOption = cloudIntegrations.find((integration) => integration.slug === "gitlab"); + + if (!integrationOption) return; + + const baseURL = gitLabURL.trim() === "" ? "https://gitlab.com" : gitLabURL.trim(); + + const csrfToken = crypto.randomBytes(16).toString("hex"); + localStorage.setItem("latestCSRFToken", csrfToken); + + const state = `${csrfToken}|${gitLabURL.trim() === "" ? "" : gitLabURL.trim()}`; + const link = `${baseURL}/oauth/authorize?client_id=${integrationOption.clientId}&redirect_uri=${window.location.origin}/integrations/gitlab/oauth2/callback&response_type=code&state=${state}`; + + window.location.assign(link); + } + + return ( +
+ + GitLab Integration + + setGitLabURL(e.target.value)} + /> + + + +
+ ); +} + +GitLabAuthorizeIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index 8c4ba6cd1..6b4c5e769 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -95,7 +95,7 @@ export default function GitLabCreateIntegrationPage() { integrationAuthId: integrationAuth?._id, isActive: true, app: integrationAuthApps?.find((integrationAuthApp) => integrationAuthApp.appId === targetAppId)?.name, - appId: targetAppId, + appId: String(targetAppId), sourceEnvironment: selectedSourceEnvironment, targetEnvironment: targetEnvironment === "" ? "*" : targetEnvironment, targetEnvironmentId: null, diff --git a/frontend/src/pages/integrations/gitlab/oauth2/callback.tsx b/frontend/src/pages/integrations/gitlab/oauth2/callback.tsx index 4df89f072..4ecf30de7 100644 --- a/frontend/src/pages/integrations/gitlab/oauth2/callback.tsx +++ b/frontend/src/pages/integrations/gitlab/oauth2/callback.tsx @@ -15,13 +15,18 @@ export default function GitLabOAuth2CallbackPage() { (async () => { try { // validate state - if (state !== localStorage.getItem("latestCSRFToken")) return; + const [csrfToken, url] = (state as string).split("|", 2); + + if (csrfToken !== localStorage.getItem("latestCSRFToken")) return; localStorage.removeItem("latestCSRFToken"); const integrationAuth = await mutateAsync({ workspaceId: localStorage.getItem("projectData.id") as string, code: code as string, - integration: "gitlab" + integration: "gitlab", + ...(url === "" ? {} : { + url + }) }); router.push(`/integrations/gitlab/create?integrationAuthId=${integrationAuth._id}`); diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 3c8ba3785..f45e16ab6 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -64,7 +64,7 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => link = `https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/integrations/github/oauth2/callback&state=${state}`; break; case "gitlab": - link = `https://gitlab.com/oauth/authorize?client_id=${integrationOption.clientId}&redirect_uri=${window.location.origin}/integrations/gitlab/oauth2/callback&response_type=code&state=${state}`; + link = `${window.location.origin}/integrations/gitlab/authorize`; break; case "render": link = `${window.location.origin}/integrations/render/authorize`;