From 359e19f804d6eac125d9acb95c72c471216224a2 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 29 Jul 2025 22:05:28 -0300 Subject: [PATCH 1/4] Add Azure Client Secrets Auth to Azure App Connections --- backend/src/lib/api-docs/constants.ts | 14 +- backend/src/lib/config/env.ts | 8 +- ...zure-app-configuration-connection-enums.ts | 3 +- .../azure-app-configuration-connection-fns.ts | 158 ++++++---- ...re-app-configuration-connection-schemas.ts | 52 +++- ...zure-app-configuration-connection-types.ts | 5 + .../azure-devops/azure-devops-enums.ts | 3 +- .../azure-devops/azure-devops-fns.ts | 143 +++++++-- .../azure-devops/azure-devops-schemas.ts | 62 +++- .../azure-devops/azure-devops-service.ts | 5 + .../azure-devops/azure-devops-types.ts | 5 + .../azure-key-vault-connection-enums.ts | 3 +- .../azure-key-vault-connection-fns.ts | 291 ++++++++++++------ .../azure-key-vault-connection-schemas.ts | 52 +++- .../azure-key-vault-connection-types.ts | 5 + .../azure-app-configuration-sync-fns.ts | 2 +- .../azure-key-vault-sync-fns.ts | 2 +- .../azure-app-configuration.mdx | 20 ++ .../app-connections/azure-devops.mdx | 26 ++ .../app-connections/azure-key-vault.mdx | 21 ++ frontend/src/helpers/appConnections.ts | 3 + .../azure-app-configuration-connection.ts | 27 +- .../types/azure-devops-connection.ts | 12 +- .../types/azure-key-vault-connection.ts | 27 +- .../AppConnectionForm/AppConnectionForm.tsx | 10 +- .../AzureAppConfigurationConnectionForm.tsx | 178 ++++++++--- .../AzureDevOpsConnectionForm.tsx | 110 ++++++- .../AzureKeyVaultConnectionForm.tsx | 177 ++++++++--- 28 files changed, 1116 insertions(+), 308 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71b6afb6b..c82a358f4 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2253,7 +2253,9 @@ export const AppConnections = { AZURE_DEVOPS: { code: "The OAuth code to use to connect with Azure DevOps.", tenantId: "The Tenant ID to use to connect with Azure DevOps.", - orgName: "The Organization name to use to connect with Azure DevOps." + orgName: "The Organization name to use to connect with Azure DevOps.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, OCI: { userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", @@ -2400,12 +2402,18 @@ export const SecretSyncs = { env: "The name of the GitHub environment." }, AZURE_KEY_VAULT: { - vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/" + vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_APP_CONFIGURATION: { configurationUrl: "The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/", - label: "An optional label to assign to secrets created in Azure App Configuration." + label: "An optional label to assign to secrets created in Azure App Configuration.", + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_DEVOPS: { devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 3fa2c0f82..9ff7339c0 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -496,7 +496,7 @@ export const overwriteSchema: { ] }, azureAppConfiguration: { - name: "Azure App Configuration", + name: "Azure App Connection: App Configuration", fields: [ { key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID", @@ -509,7 +509,7 @@ export const overwriteSchema: { ] }, azureKeyVault: { - name: "Azure Key Vault", + name: "Azure App Connection: Key Vault", fields: [ { key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID", @@ -522,7 +522,7 @@ export const overwriteSchema: { ] }, azureClientSecrets: { - name: "Azure Client Secrets", + name: "Azure App Connection: Client Secrets", fields: [ { key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID", @@ -535,7 +535,7 @@ export const overwriteSchema: { ] }, azureDevOps: { - name: "Azure DevOps", + name: "Azure App Connection: DevOps", fields: [ { key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID", diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts index 450cb9255..b79d9c67a 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-enums.ts @@ -1,3 +1,4 @@ export enum AzureAppConfigurationConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts index 114794dc5..80e65a821 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -19,7 +20,10 @@ export const getAzureAppConfigurationConnectionListItem = () => { return { name: "Azure App Configuration" as const, app: AppConnection.AzureAppConfiguration as const, - methods: Object.values(AzureAppConfigurationConnectionMethod) as [AzureAppConfigurationConnectionMethod.OAuth], + methods: Object.values(AzureAppConfigurationConnectionMethod) as [ + AzureAppConfigurationConnectionMethod.OAuth, + AzureAppConfigurationConnectionMethod.ClientSecret + ], oauthClientId: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID }; }; @@ -35,71 +39,111 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( SITE_URL } = getConfig(); - if ( - !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || - !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET - ) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://azconfig.io/.default`, - client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } - switch (method) { case AzureAppConfigurationConnectionMethod.OAuth: + if ( + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET + ) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + const oauthCredentials = inputCredentials as { code: string; tenantId?: string }; + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `openid offline_access https://azconfig.io/.default`, + client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { - tenantId: inputCredentials.tenantId, + tenantId: oauthCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureAppConfigurationConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + }; + + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://azconfig.io/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureAppConfigurationConnectionMethod}` + message: `Unhandled Azure App Configuration connection method: ${method as AzureAppConfigurationConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts index 183376acf..68579f9ba 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts @@ -22,6 +22,29 @@ export const AzureAppConfigurationConnectionOAuthOutputCredentialsSchema = z.obj expiresAt: z.number() }); +export const AzureAppConfigurationConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long"), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long"), + tenantId: z.string().uuid().trim().min(1, "Tenant ID required") +}); + +export const AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -30,6 +53,14 @@ export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discri credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials ) + }), + z.object({ + method: z + .literal(AzureAppConfigurationConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureAppConfiguration).method), + credentials: AzureAppConfigurationConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials + ) }) ]); @@ -39,9 +70,13 @@ export const CreateAzureAppConfigurationConnectionSchema = ValidateAzureAppConfi export const UpdateAzureAppConfigurationConnectionSchema = z .object({ - credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials - ) + credentials: z + .union([ + AzureAppConfigurationConnectionOAuthInputCredentialsSchema, + AzureAppConfigurationConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureAppConfiguration)); @@ -55,6 +90,10 @@ export const AzureAppConfigurationConnectionSchema = z.intersection( z.object({ method: z.literal(AzureAppConfigurationConnectionMethod.OAuth), credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -65,6 +104,13 @@ export const SanitizedAzureAppConfigurationConnectionSchema = z.discriminatedUni credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureAppConfigurationConnectionSchema.extend({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts index 8111b4c50..e60161197 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema, AzureAppConfigurationConnectionOAuthOutputCredentialsSchema, AzureAppConfigurationConnectionSchema, CreateAzureAppConfigurationConnectionSchema, @@ -39,3 +40,7 @@ export type ExchangeCodeAzureResponse = { export type TAzureAppConfigurationConnectionCredentials = z.infer< typeof AzureAppConfigurationConnectionOAuthOutputCredentialsSchema >; + +export type TAzureAppConfigurationConnectionClientSecretCredentials = z.infer< + typeof AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema +>; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts index f724a0b4a..c0ce74b27 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-enums.ts @@ -1,4 +1,5 @@ export enum AzureDevOpsConnectionMethod { OAuth = "oauth", - AccessToken = "access-token" + AccessToken = "access-token", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts index e9bb1f6bd..39a330360 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -18,6 +18,7 @@ import { AppConnection } from "../app-connection-enums"; import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; import { ExchangeCodeAzureResponse, + TAzureDevOpsConnectionClientSecretCredentials, TAzureDevOpsConnectionConfig, TAzureDevOpsConnectionCredentials } from "./azure-devops-types"; @@ -30,7 +31,8 @@ export const getAzureDevopsConnectionListItem = () => { app: AppConnection.AzureDevOps as const, methods: Object.values(AzureDevOpsConnectionMethod) as [ AzureDevOpsConnectionMethod.OAuth, - AzureDevOpsConnectionMethod.AccessToken + AzureDevOpsConnectionMethod.AccessToken, + AzureDevOpsConnectionMethod.ClientSecret ], oauthClientId: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID }; @@ -53,11 +55,7 @@ export const getAzureDevopsConnection = async ( }); } - const credentials = (await decryptAppConnectionCredentials({ - orgId: appConnection.orgId, - kmsService, - encryptedCredentials: appConnection.encryptedCredentials - })) as TAzureDevOpsConnectionCredentials; + const currentTime = Date.now(); // Handle different connection methods switch (appConnection.method) { @@ -69,12 +67,17 @@ export const getAzureDevopsConnection = async ( }); } - if (!("refreshToken" in credentials)) { + const oauthCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionCredentials; + + if (!("refreshToken" in oauthCredentials)) { throw new BadRequestError({ message: "Invalid OAuth credentials" }); } - const { refreshToken, tenantId } = credentials; - const currentTime = Date.now(); + const { refreshToken, tenantId } = oauthCredentials; const { data } = await request.post( IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), @@ -87,29 +90,75 @@ export const getAzureDevopsConnection = async ( }) ); - const updatedCredentials = { - ...credentials, + const updatedOAuthCredentials = { + ...oauthCredentials, accessToken: data.access_token, expiresAt: currentTime + data.expires_in * 1000, refreshToken: data.refresh_token }; - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, + const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ + credentials: updatedOAuthCredentials, orgId: appConnection.orgId, kmsService }); - await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); return data.access_token; case AzureDevOpsConnectionMethod.AccessToken: - if (!("accessToken" in credentials)) { + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as { accessToken: string }; + + if (!("accessToken" in accessTokenCredentials)) { throw new BadRequestError({ message: "Invalid API token credentials" }); } // For access token, return the basic auth token directly - return credentials.accessToken; + return accessTokenCredentials.accessToken; + + case AzureDevOpsConnectionMethod.ClientSecret: + const clientSecretCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureDevOpsConnectionClientSecretCredentials; + + const { accessToken, expiresAt, clientId, clientSecret, tenantId: clientTenantId } = clientSecretCredentials; + + // Check if token is still valid (with 5 minute buffer) + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } + + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", clientTenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...clientSecretCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; default: throw new BadRequestError({ message: `Unsupported connection method` }); @@ -138,7 +187,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev let tokenError: AxiosError | null = null; try { - const oauthCredentials = inputCredentials as { code: string; tenantId: string }; + const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string }; tokenResp = await request.post( IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), new URLSearchParams({ @@ -262,9 +311,67 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev }); } + case AzureDevOpsConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret, orgName } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + orgName: string; + }; + + try { + // First, get the access token using client credentials flow + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://app.vssps.visualstudio.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + // Validate access to the specific organization + const response = await request.get( + `${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/projects?api-version=7.2-preview.2&$top=1`, + { + headers: { + Authorization: `Bearer ${clientData.access_token}` + } + } + ); + + if (response.status !== 200) { + throw new BadRequestError({ + message: `Failed to validate connection to organization '${orgName}': ${response.status}` + }); + } + + return { + tenantId, + clientId, + clientSecret, + orgName, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000 + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to authenticate with Azure DevOps using client credentials: ${ + (e?.response?.data as { error_description?: string })?.error_description || e.message + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to validate Azure DevOps client credentials" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureDevOpsConnectionMethod}` + message: `Unhandled Azure DevOps connection method: ${method as AzureDevOpsConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts index e07ed008d..2580d0114 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts @@ -38,6 +38,42 @@ export const AzureDevOpsConnectionAccessTokenOutputCredentialsSchema = z.object( orgName: z.string() }); +export const AzureDevOpsConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientId), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientSecret), + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.tenantId), + orgName: z + .string() + .trim() + .min(1, "Organization name required") + .describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.orgName) +}); + +export const AzureDevOpsConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + orgName: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -54,6 +90,14 @@ export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUni credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureDevOps).credentials ) + }), + z.object({ + method: z + .literal(AzureDevOpsConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureDevOps).method), + credentials: AzureDevOpsConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureDevOps).credentials + ) }) ]); @@ -64,7 +108,11 @@ export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCr export const UpdateAzureDevOpsConnectionSchema = z .object({ credentials: z - .union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionAccessTokenInputCredentialsSchema]) + .union([ + AzureDevOpsConnectionOAuthInputCredentialsSchema, + AzureDevOpsConnectionAccessTokenInputCredentialsSchema, + AzureDevOpsConnectionClientSecretInputCredentialsSchema + ]) .optional() .describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials) }) @@ -84,6 +132,10 @@ export const AzureDevOpsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureDevOpsConnectionMethod.AccessToken), credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), + credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -101,6 +153,14 @@ export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({ orgName: true }) + }), + BaseAzureDevOpsConnectionSchema.extend({ + method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), + credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true, + orgName: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts index a85430e4d..6006dc1b4 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-service.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-service.ts @@ -52,6 +52,11 @@ const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: stri Authorization: `Basic ${basicAuthToken}`, Accept: "application/json" }; + case AzureDevOpsConnectionMethod.ClientSecret: + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + }; default: throw new BadRequestError({ message: "Unsupported connection method" }); } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts index 62a80c1c5..f556b4600 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-types.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureDevOpsConnectionClientSecretOutputCredentialsSchema, AzureDevOpsConnectionOAuthOutputCredentialsSchema, AzureDevOpsConnectionSchema, CreateAzureDevOpsConnectionSchema, @@ -27,6 +28,10 @@ export type TAzureDevOpsConnectionConfig = DiscriminativePick< export type TAzureDevOpsConnectionCredentials = z.infer; +export type TAzureDevOpsConnectionClientSecretCredentials = z.infer< + typeof AzureDevOpsConnectionClientSecretOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts index 895e88298..aa70ccb68 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-enums.ts @@ -1,3 +1,4 @@ export enum AzureKeyVaultConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 95102c5d1..1f88488c0 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -16,25 +17,16 @@ import { AppConnection } from "../app-connection-enums"; import { AzureKeyVaultConnectionMethod } from "./azure-key-vault-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureKeyVaultConnectionClientSecretCredentials, TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionCredentials } from "./azure-key-vault-connection-types"; export const getAzureConnectionAccessToken = async ( connectionId: string, - appConnectionDAL: Pick, + appConnectionDAL: Pick, kmsService: Pick ) => { - const appCfg = getConfig(); - if ( - !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || - !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET - ) { - throw new BadRequestError({ - message: `Azure environment variables have not been configured` - }); - } - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) { @@ -49,49 +41,101 @@ export const getAzureConnectionAccessToken = async ( throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); } - const credentials = (await decryptAppConnectionCredentials({ - orgId: appConnection.orgId, - kmsService, - encryptedCredentials: appConnection.encryptedCredentials - })) as TAzureKeyVaultConnectionCredentials; + const currentTime = Date.now(); - const { data } = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "refresh_token", - scope: `openid offline_access`, - client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, - client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, - refresh_token: credentials.refreshToken - }) - ); + switch (appConnection.method) { + case AzureKeyVaultConnectionMethod.OAuth: + const appCfg = getConfig(); + if ( + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET + ) { + throw new BadRequestError({ + message: `Azure environment variables have not been configured` + }); + } - const accessExpiresAt = new Date(); - accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); + const oauthCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureKeyVaultConnectionCredentials; - const updatedCredentials = { - ...credentials, - accessToken: data.access_token, - expiresAt: accessExpiresAt.getTime(), - refreshToken: data.refresh_token - }; + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://vault.azure.net/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, + refresh_token: oauthCredentials.refreshToken + }) + ); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, - orgId: appConnection.orgId, - kmsService - }); + const updatedOAuthCredentials = { + ...oauthCredentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; - await appConnectionDAL.update( - { id: connectionId }, - { - encryptedCredentials - } - ); + const encryptedOAuthCredentials = await encryptAppConnectionCredentials({ + credentials: updatedOAuthCredentials, + orgId: appConnection.orgId, + kmsService + }); - return { - accessToken: data.access_token - }; + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials }); + + return { + accessToken: data.access_token + }; + + case AzureKeyVaultConnectionMethod.ClientSecret: + const clientSecretCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureKeyVaultConnectionClientSecretCredentials; + + const { accessToken, expiresAt, clientId, clientSecret, tenantId } = clientSecretCredentials; + + // Check if token is still valid (with 5 minute buffer) + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return { accessToken }; + } + + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://vault.azure.net/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...clientSecretCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return { accessToken: clientData.access_token }; + + default: + throw new InternalServerError({ + message: `Unhandled Azure Key Vault connection method: ${appConnection.method as AzureKeyVaultConnectionMethod}` + }); + } }; export const getAzureKeyVaultConnectionListItem = () => { @@ -100,7 +144,10 @@ export const getAzureKeyVaultConnectionListItem = () => { return { name: "Azure Key Vault" as const, app: AppConnection.AzureKeyVault as const, - methods: Object.values(AzureKeyVaultConnectionMethod) as [AzureKeyVaultConnectionMethod.OAuth], + methods: Object.values(AzureKeyVaultConnectionMethod) as [ + AzureKeyVaultConnectionMethod.OAuth, + AzureKeyVaultConnectionMethod.ClientSecret + ], oauthClientId: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID }; }; @@ -111,68 +158,108 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, SITE_URL } = getConfig(); - if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://vault.azure.net/.default`, - client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } - switch (method) { case AzureKeyVaultConnectionMethod.OAuth: + if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + const oauthCredentials = inputCredentials as { code: string; tenantId?: string }; + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: oauthCredentials.code, + scope: `openid offline_access https://vault.azure.net/.default`, + client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { - tenantId: inputCredentials.tenantId, + tenantId: oauthCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureKeyVaultConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials as { + tenantId: string; + clientId: string; + clientSecret: string; + }; + + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://vault.azure.net/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + default: throw new InternalServerError({ - message: `Unhandled Azure connection method: ${method as AzureKeyVaultConnectionMethod}` + message: `Unhandled Azure Key Vault connection method: ${method as AzureKeyVaultConnectionMethod}` }); } }; diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts index f3c7c43b8..d86878bab 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts @@ -22,6 +22,29 @@ export const AzureKeyVaultConnectionOAuthOutputCredentialsSchema = z.object({ expiresAt: z.number() }); +export const AzureKeyVaultConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long"), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long"), + tenantId: z.string().uuid().trim().min(1, "Tenant ID required") +}); + +export const AzureKeyVaultConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -30,6 +53,14 @@ export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedU credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureKeyVault).credentials ) + }), + z.object({ + method: z + .literal(AzureKeyVaultConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureKeyVault).method), + credentials: AzureKeyVaultConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureKeyVault).credentials + ) }) ]); @@ -39,9 +70,13 @@ export const CreateAzureKeyVaultConnectionSchema = ValidateAzureKeyVaultConnecti export const UpdateAzureKeyVaultConnectionSchema = z .object({ - credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials - ) + credentials: z + .union([ + AzureKeyVaultConnectionOAuthInputCredentialsSchema, + AzureKeyVaultConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureKeyVault)); @@ -55,6 +90,10 @@ export const AzureKeyVaultConnectionSchema = z.intersection( z.object({ method: z.literal(AzureKeyVaultConnectionMethod.OAuth), credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), + credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -65,6 +104,13 @@ export const SanitizedAzureKeyVaultConnectionSchema = z.discriminatedUnion("meth credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureKeyVaultConnectionSchema.extend({ + method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), + credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts index 95e68952a..5c5c89b95 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureKeyVaultConnectionClientSecretOutputCredentialsSchema, AzureKeyVaultConnectionOAuthOutputCredentialsSchema, AzureKeyVaultConnectionSchema, CreateAzureKeyVaultConnectionSchema, @@ -36,3 +37,7 @@ export type ExchangeCodeAzureResponse = { }; export type TAzureKeyVaultConnectionCredentials = z.infer; + +export type TAzureKeyVaultConnectionClientSecretCredentials = z.infer< + typeof AzureKeyVaultConnectionClientSecretOutputCredentialsSchema +>; diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts index 7aa1c16ce..51669067a 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -13,7 +13,7 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types"; type TAzureAppConfigurationSyncFactoryDeps = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index edc8af709..a4cf59909 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -12,7 +12,7 @@ import { SecretSyncError } from "../secret-sync-errors"; import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types"; type TAzureKeyVaultSyncFactoryDeps = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; kmsService: Pick; }; diff --git a/docs/integrations/app-connections/azure-app-configuration.mdx b/docs/integrations/app-connections/azure-app-configuration.mdx index 679839878..c8c4895f9 100644 --- a/docs/integrations/app-connections/azure-app-configuration.mdx +++ b/docs/integrations/app-connections/azure-app-configuration.mdx @@ -58,6 +58,26 @@ Infisical currently only supports one method for connecting to Azure, which is O + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure App Configuration resources you want to use. + + **Prerequisites:** + - Set up Azure and have an existing App Configuration instance. + - The service principal must be connected to your target Azure App Configuration resource(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure App Configuration: + + #### Azure App Configuration permissions + + Set the API permissions of your Azure service principal to include the following Azure App Configuration permissions: `KeyValue.Delete`, `KeyValue.Read`, and `KeyValue.Write`. + + ![Azure app config](../../images/integrations/azure-app-configuration/app-api-permissions.png) + + + ## Setup Azure Connection in Infisical diff --git a/docs/integrations/app-connections/azure-devops.mdx b/docs/integrations/app-connections/azure-devops.mdx index 6a9e71430..44ff55e64 100644 --- a/docs/integrations/app-connections/azure-devops.mdx +++ b/docs/integrations/app-connections/azure-devops.mdx @@ -87,6 +87,32 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure DevOps organization and projects you want to use. + + **Prerequisites:** + - Set up Azure and have an existing Azure DevOps organization. + - The service principal must be connected to your target Azure DevOps organization and project(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure DevOps: + + #### Azure DevOps permissions + + Set the API permissions of your Azure service principal to include the following Azure DevOps permissions: + - Azure DevOps + - `user_impersonation` + - `vso.project_write` + - `vso.variablegroups_manage` + - `vso.variablegroups_write` + + ![Azure devops](/images/integrations/azure-devops/app-api-permissions.png) + + + + ## Setup Azure Connection in Infisical diff --git a/docs/integrations/app-connections/azure-key-vault.mdx b/docs/integrations/app-connections/azure-key-vault.mdx index 22cdcf637..1847ec607 100644 --- a/docs/integrations/app-connections/azure-key-vault.mdx +++ b/docs/integrations/app-connections/azure-key-vault.mdx @@ -58,6 +58,27 @@ Infisical currently only supports one method for connecting to Azure, which is O + + To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure Key Vault instances you want to use. + + **Prerequisites:** + - Set up Azure and have an existing Key Vault instance. + - The service principal must be connected to your target Azure Key Vault instance(s) + + + + + Configure the required API permissions for your App Registration to interact with Azure Key Vault: + + #### Azure Key Vault permissions + + Set the API permissions of your Azure service principal to include `user_impersonation` for the Key Vault API. + ![Azure key vault](/images/app-connections/azure/keyvault-azure-permissions.png) + + + + + ## Setup Azure Connection in Infisical diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 8c8475767..44111a5d7 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -172,6 +172,9 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case ChecklyConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; case AzureClientSecretsConnectionMethod.ClientSecret: + case AzureAppConfigurationConnectionMethod.ClientSecret: + case AzureKeyVaultConnectionMethod.ClientSecret: + case AzureDevOpsConnectionMethod.ClientSecret: return { name: "Client Secret", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); diff --git a/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts index b56003cad..092822da2 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-app-configuration-connection.ts @@ -2,15 +2,26 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureAppConfigurationConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } export type TAzureAppConfigurationConnection = TRootAppConnection & { app: AppConnection.AzureAppConfiguration; -} & { - method: AzureAppConfigurationConnectionMethod.OAuth; - credentials: { - code: string; - tenantId?: string; - }; -}; +} & ( + | { + method: AzureAppConfigurationConnectionMethod.OAuth; + credentials: { + code: string; + tenantId?: string; + }; + } + | { + method: AzureAppConfigurationConnectionMethod.ClientSecret; + credentials: { + clientId: string; + clientSecret: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts index ef30936e6..9334ac06a 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-devops-connection.ts @@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con export enum AzureDevOpsConnectionMethod { OAuth = "oauth", - AccessToken = "access-token" + AccessToken = "access-token", + ClientSecret = "client-secret" } export type TAzureDevOpsConnection = TRootAppConnection & { @@ -24,4 +25,13 @@ export type TAzureDevOpsConnection = TRootAppConnection & { orgName: string; }; } + | { + method: AzureDevOpsConnectionMethod.ClientSecret; + credentials: { + clientSecret: string; + tenantId: string; + clientId: string; + orgName: string; + }; + } ); diff --git a/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts index 68888531a..5cea148fa 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-key-vault-connection.ts @@ -2,13 +2,24 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureKeyVaultConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } -export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & { - method: AzureKeyVaultConnectionMethod.OAuth; - credentials: { - code: string; - tenantId?: string; - }; -}; +export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & ( + | { + method: AzureKeyVaultConnectionMethod.OAuth; + credentials: { + code: string; + tenantId?: string; + }; + } + | { + method: AzureKeyVaultConnectionMethod.ClientSecret; + credentials: { + clientId: string; + clientSecret: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index d4b87af81..2ee99119a 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -92,9 +92,9 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { case AppConnection.GCP: return ; case AppConnection.AzureKeyVault: - return ; + return ; case AppConnection.AzureAppConfiguration: - return ; + return ; case AppConnection.Databricks: return ; case AppConnection.Humanitec: @@ -200,9 +200,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { case AppConnection.GCP: return ; case AppConnection.AzureKeyVault: - return ; + return ; case AppConnection.AzureAppConfiguration: - return ; + return ( + + ); case AppConnection.Databricks: return ; case AppConnection.Humanitec: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureAppConfigurationConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureAppConfigurationConnectionForm.tsx index 75449fed3..c33541a24 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureAppConfigurationConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureAppConfigurationConnectionForm.tsx @@ -20,19 +20,83 @@ import { GenericAppConnectionsFields } from "./GenericAppConnectionFields"; +type ClientSecretForm = z.infer; + type Props = { appConnection?: TAzureAppConfigurationConnection; + onSubmit: (formData: ClientSecretForm) => Promise; }; -const formSchema = genericAppConnectionFieldsSchema.extend({ +const baseSchema = genericAppConnectionFieldsSchema.extend({ app: z.literal(AppConnection.AzureAppConfiguration), - method: z.nativeEnum(AzureAppConfigurationConnectionMethod), - tenantId: z.string().trim().optional() + method: z.nativeEnum(AzureAppConfigurationConnectionMethod) }); +const oauthSchema = baseSchema.extend({ + tenantId: z.string().trim().min(1, "Tenant ID is required"), + method: z.literal(AzureAppConfigurationConnectionMethod.OAuth) +}); + +const clientSecretSchema = baseSchema.extend({ + method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), + credentials: z.object({ + clientSecret: z.string().trim().min(1, "Client Secret is required"), + clientId: z.string().trim().min(1, "Client ID is required"), + tenantId: z.string().trim().min(1, "Tenant ID is required") + }) +}); + +const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]); + type FormData = z.infer; -export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => { +const getDefaultValues = (appConnection?: TAzureAppConfigurationConnection): Partial => { + if (!appConnection) { + return { + app: AppConnection.AzureAppConfiguration, + method: AzureAppConfigurationConnectionMethod.OAuth + }; + } + + const base = { + name: appConnection.name, + description: appConnection.description, + app: appConnection.app, + method: appConnection.method + }; + const { credentials } = appConnection; + + switch (appConnection.method) { + case AzureAppConfigurationConnectionMethod.OAuth: + if ("tenantId" in credentials) { + return { + ...base, + method: AzureAppConfigurationConnectionMethod.OAuth, + tenantId: credentials.tenantId + }; + } + break; + case AzureAppConfigurationConnectionMethod.ClientSecret: + if ("clientSecret" in credentials && "clientId" in credentials) { + return { + ...base, + method: AzureAppConfigurationConnectionMethod.ClientSecret, + credentials: { + clientSecret: credentials.clientSecret, + clientId: credentials.clientId, + tenantId: credentials.tenantId + } + }; + } + break; + default: + return base; + } + + return base; +}; + +export const AzureAppConfigurationConnectionForm = ({ appConnection, onSubmit }: Props) => { const isUpdate = Boolean(appConnection); const [isRedirecting, setIsRedirecting] = useState(false); @@ -43,41 +107,36 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: appConnection - ? { - ...appConnection, - tenantId: appConnection.credentials.tenantId - } - : { - app: AppConnection.AzureAppConfiguration, - method: AzureAppConfigurationConnectionMethod.OAuth - } + defaultValues: getDefaultValues(appConnection) }); const { handleSubmit, control, watch, + setValue, formState: { isSubmitting, isDirty } } = form; const selectedMethod = watch("method"); - const onSubmit = (formData: FormData) => { - setIsRedirecting(true); + const onSubmitHandler = (formData: FormData) => { const state = crypto.randomBytes(16).toString("hex"); - localStorage.setItem("latestCSRFToken", state); - localStorage.setItem( - "azureAppConfigurationConnectionFormData", - JSON.stringify({ ...formData, connectionId: appConnection?.id }) - ); - switch (formData.method) { case AzureAppConfigurationConnectionMethod.OAuth: + setIsRedirecting(true); + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "azureAppConfigurationConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) + ); window.location.assign( `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-app-configuration` ); break; + case AzureAppConfigurationConnectionMethod.ClientSecret: + onSubmit(formData); + break; default: throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); } @@ -89,6 +148,9 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => case AzureAppConfigurationConnectionMethod.OAuth: isMissingConfig = !oauthClientId; break; + case AzureAppConfigurationConnectionMethod.ClientSecret: + isMissingConfig = false; + break; default: throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`); } @@ -97,25 +159,8 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => return ( -
+ {!isUpdate && } - - ( - - - - )} - /> - )} /> + + ( + + { + field.onChange(e.target.value); + setValue("credentials.tenantId", e.target.value); + }} + /> + + )} + /> + + {/* Client Secret-specific fields */} + {selectedMethod === AzureAppConfigurationConnectionMethod.ClientSecret && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )}