Merge pull request #4820 from Infisical/daniel/cert-auth

feat(app-connections/azure-client-secrets): certificate auth
This commit is contained in:
Daniel Hougaard
2025-11-08 04:42:54 +04:00
committed by GitHub
12 changed files with 448 additions and 35 deletions
+4 -1
View File
@@ -2309,7 +2309,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.",
certificateBody: "The certificate body in PEM format 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.",
@@ -1,4 +1,5 @@
export enum AzureClientSecretsConnectionMethod {
OAuth = "oauth",
ClientSecret = "client-secret"
ClientSecret = "client-secret",
Certificate = "certificate"
}
@@ -1,9 +1,14 @@
/* eslint-disable no-case-declarations */
import { AxiosError, AxiosResponse } from "axios";
import type { KeyObject } from "crypto";
import RE2 from "re2";
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 +22,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(new RE2("-----BEGIN CERTIFICATE-----"), "")
.replace(new RE2("-----END CERTIFICATE-----"), "")
.replace(new RE2("\\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 +106,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 +141,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 +178,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 +217,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, certificateBody, privateKey } = accessTokenCredentials;
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
return accessToken;
}
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
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 +278,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 +343,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<ExchangeCodeAzureResponse>(
@@ -255,6 +378,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
});
}
}
}
case AzureClientSecretsConnectionMethod.Certificate: {
const { tenantId, certificateBody, privateKey, clientId } = inputCredentials;
try {
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
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<ExchangeCodeAzureResponse>(tokenEndpoint, params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
return {
tenantId,
clientId,
certificateBody,
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}`
@@ -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),
certificateBody: z
.string()
.trim()
.min(1, "Certificate body required")
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificateBody),
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(),
certificateBody: 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
})
})
]);
@@ -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;