mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-10 08:28:52 +00:00
Merge pull request #4820 from Infisical/daniel/cert-auth
feat(app-connections/azure-client-secrets): certificate auth
This commit is contained in:
@@ -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.",
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
export enum AzureClientSecretsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
ClientSecret = "client-secret",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
+179
-5
@@ -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}`
|
||||
|
||||
+55
-1
@@ -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
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user