From 32587a3c999cb0a8731dd0f58da15d0f42b14f0b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Thu, 6 Nov 2025 05:59:57 +0400 Subject: [PATCH 1/3] feat(app-connections/azure-client-secrets): certificate auth --- backend/src/lib/api-docs/constants.ts | 5 +- .../azure-client-secrets-connection-enums.ts | 3 +- .../azure-client-secrets-connection-fns.ts | 183 +++++++++++++++++- ...azure-client-secrets-connection-schemas.ts | 56 +++++- .../azure-client-secrets-connection-types.ts | 5 + .../components/v2/SecretInput/SecretInput.tsx | 16 +- frontend/src/helpers/appConnections.ts | 3 + .../types/azure-client-secrets-connection.ts | 12 +- .../AzureClientSecretsConnectionForm.tsx | 101 +++++++++- 9 files changed, 369 insertions(+), 15 deletions(-) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cb606cbf..b0829bd3b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2308,7 +2308,10 @@ export const AppConnections = { code: "The OAuth code to use to connect with Azure Client Secrets.", 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." + clientSecret: "The Client Secret to use to connect with Azure Client Secrets.", + certificate: "The certificate to use to connect with Azure Client Secrets.", + privateKey: + "The private key to use to connect with Azure Client Secrets. This is never transmitted to Azure and is only used to sign the Azure client assertion with." }, AZURE_DEVOPS: { code: "The OAuth code to use to connect with Azure DevOps.", diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts index eb0521c64..1f7fc808d 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts @@ -1,4 +1,5 @@ export enum AzureClientSecretsConnectionMethod { OAuth = "oauth", - ClientSecret = "client-secret" + ClientSecret = "client-secret", + Certificate = "certificate" } diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 22cec0ae7..d1599cd3e 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -1,9 +1,13 @@ /* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; +import type { KeyObject } from "crypto"; +import { v4 as uuidv4 } from "uuid"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { decryptAppConnectionCredentials, encryptAppConnectionCredentials, @@ -17,11 +21,82 @@ import { AppConnection } from "../app-connection-enums"; import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureClientSecretsConnectionCertificateCredentials, TAzureClientSecretsConnectionClientSecretCredentials, TAzureClientSecretsConnectionConfig, TAzureClientSecretsConnectionCredentials } from "./azure-client-secrets-connection-types"; +const generateClientAssertion = ( + clientId: string, + tenantId: string, + privateKey: string, + certificate: string +): string => { + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const certBuffer = Buffer.from( + certificate + .replace(/-----BEGIN CERTIFICATE-----/, "") + .replace(/-----END CERTIFICATE-----/, "") + .replace(/\s/g, ""), + "base64" + ); + + // thumbprint of the certificate is used for the jwt header + const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex"); + const x5t = Buffer.from(thumbprint, "hex").toString("base64url"); + + // JWT Header + const header = { + alg: "RS256", + typ: "JWT", + x5t + }; + + const now = Math.floor(Date.now() / 1000); + const payload = { + aud: tokenEndpoint, + exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself) + iss: clientId, + jti: uuidv4(), // random ID for the JWT + nbf: now, // not before the jwt is valid + sub: clientId + }; + + // encode header and payload + const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signatureInput = `${encodedHeader}.${encodedPayload}`; + + let keyObject: KeyObject; + + try { + if (privateKey.includes("BEGIN PRIVATE KEY")) { + keyObject = crypto.nativeCrypto.createPrivateKey(privateKey); + } else { + // if user forgot to wrap in begin/end private key, decode and use as der format + keyObject = crypto.nativeCrypto.createPrivateKey({ + key: Buffer.from(privateKey, "base64"), + format: "der", + type: "pkcs8" + }); + } + } catch (error) { + throw new BadRequestError({ + message: "Invalid private key format provided. Expected PEM format private key." + }); + } + + // sign with private key + const signer = crypto.nativeCrypto.createSign("RSA-SHA256"); + signer.update(signatureInput); + signer.end(); + const signature = signer.sign(keyObject, "base64url"); + + return `${signatureInput}.${signature}`; +}; + export const getAzureClientSecretsConnectionListItem = () => { const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig(); @@ -30,7 +105,8 @@ export const getAzureClientSecretsConnectionListItem = () => { app: AppConnection.AzureClientSecrets as const, methods: Object.values(AzureClientSecretsConnectionMethod) as [ AzureClientSecretsConnectionMethod.OAuth, - AzureClientSecretsConnectionMethod.ClientSecret + AzureClientSecretsConnectionMethod.ClientSecret, + AzureClientSecretsConnectionMethod.Certificate ], oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID }; @@ -64,7 +140,7 @@ export const getAzureConnectionAccessToken = async ( const { refreshToken } = credentials; const currentTime = Date.now(); switch (appConnection.method) { - case AzureClientSecretsConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: { if ( !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET @@ -101,7 +177,8 @@ export const getAzureConnectionAccessToken = async ( await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); return data.access_token; - case AzureClientSecretsConnectionMethod.ClientSecret: + } + case AzureClientSecretsConnectionMethod.ClientSecret: { const accessTokenCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, projectId: appConnection.projectId, @@ -139,6 +216,50 @@ export const getAzureConnectionAccessToken = async ( await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); return clientData.access_token; + } + + case AzureClientSecretsConnectionMethod.Certificate: { + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + projectId: appConnection.projectId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionCertificateCredentials; + const { accessToken, expiresAt, clientId, tenantId, certificate, privateKey } = accessTokenCredentials; + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } + + const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificate); + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://graph.microsoft.com/.default`, + client_id: clientId, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion + }) + ); + + const updatedClientCredentials = { + ...accessTokenCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + projectId: appConnection.projectId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; + } + default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}` @@ -156,7 +277,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA } = getConfig(); switch (method) { - case AzureClientSecretsConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: { if (!SITE_URL) { throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); } @@ -221,8 +342,9 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + } - case AzureClientSecretsConnectionMethod.ClientSecret: + case AzureClientSecretsConnectionMethod.ClientSecret: { const { tenantId, clientId, clientSecret } = inputCredentials; try { const { data: clientData } = await request.post( @@ -255,6 +377,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA }); } } + } + case AzureClientSecretsConnectionMethod.Certificate: { + const { tenantId, certificate, privateKey, clientId } = inputCredentials; + try { + const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificate); + + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const params = new URLSearchParams({ + client_id: clientId, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion, + scope: "https://graph.microsoft.com/.default", + grant_type: "client_credentials" + }); + + const response = await request.post(tokenEndpoint, params.toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); + + return { + tenantId, + clientId, + certificate, + privateKey, + accessToken: response.data.access_token, + expiresAt: Date.now() + response.data.expires_in * 1000 + }; + } 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 if (e instanceof BadRequestError) { + throw e; + } else { + logger.error( + e, + "validateAzureClientSecretsConnectionCredentials: Failed to get access token using certificate authentication" + ); + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + } + default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts index d9f178a06..3f4130e28 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z. .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) }); +export const AzureClientSecretsConnectionCertificateInputCredentialsSchema = z.object({ + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId), + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId), + certificate: z + .string() + .trim() + .min(1, "Certificate required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificate), + privateKey: z + .string() + .trim() + .min(1, "Private Key required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey) +}); + export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({ clientId: z.string(), clientSecret: z.string(), @@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z expiresAt: z.number() }); +export const AzureClientSecretsConnectionCertificateOutputCredentialsSchema = z.object({ + clientId: z.string(), + tenantId: z.string(), + certificate: z.string(), + privateKey: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials ) + }), + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.Certificate) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionCertificateInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) }) ]); @@ -84,7 +126,8 @@ export const UpdateAzureClientSecretsConnectionSchema = z credentials: z .union([ AzureClientSecretsConnectionOAuthInputCredentialsSchema, - AzureClientSecretsConnectionClientSecretInputCredentialsSchema + AzureClientSecretsConnectionClientSecretInputCredentialsSchema, + AzureClientSecretsConnectionCertificateInputCredentialsSchema ]) .optional() .describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials) @@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.Certificate), + credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema }) ]) ); @@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion( clientId: true, tenantId: true }) + }), + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.Certificate), + credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({ + tenantId: true, + clientId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts index 1ad5a3411..e8a66cbd9 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureClientSecretsConnectionCertificateOutputCredentialsSchema, AzureClientSecretsConnectionClientSecretOutputCredentialsSchema, AzureClientSecretsConnectionOAuthOutputCredentialsSchema, AzureClientSecretsConnectionSchema, @@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer< typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema >; +export type TAzureClientSecretsConnectionCertificateCredentials = z.infer< + typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 93077c07f..27f0617ed 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -12,12 +12,14 @@ const syntaxHighlight = ( isVisible?: boolean, isImport?: boolean, isLoadingValue?: boolean, - isErrorLoadingValue?: boolean + isErrorLoadingValue?: boolean, + placeholder?: string ) => { if (isLoadingValue) return HIDDEN_SECRET_VALUE; if (isErrorLoadingValue) return Error loading secret value.; if (isImport && !content) return "IMPORTED"; + if (placeholder && (content === "" || !content)) return placeholder; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; if (!isVisible) return HIDDEN_SECRET_VALUE; @@ -79,6 +81,7 @@ export const SecretInput = forwardRef( canEditButNotView, isLoadingValue, isErrorLoadingValue, + placeholder, ...props }, ref @@ -93,18 +96,25 @@ export const SecretInput = forwardRef(
             
-              
+              
                 {syntaxHighlight(
                   value,
                   isVisible || (isSecretFocused && !valueAlwaysHidden),
                   isImport,
                   isLoadingValue,
-                  isErrorLoadingValue
+                  isErrorLoadingValue,
+                  placeholder
                 )}