mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 16:27:40 +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.",
|
code: "The OAuth code to use to connect with Azure Client Secrets.",
|
||||||
tenantId: "The Tenant ID 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.",
|
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: {
|
AZURE_DEVOPS: {
|
||||||
code: "The OAuth code to use to connect with Azure DevOps.",
|
code: "The OAuth code to use to connect with Azure DevOps.",
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
export enum AzureClientSecretsConnectionMethod {
|
export enum AzureClientSecretsConnectionMethod {
|
||||||
OAuth = "oauth",
|
OAuth = "oauth",
|
||||||
ClientSecret = "client-secret"
|
ClientSecret = "client-secret",
|
||||||
|
Certificate = "certificate"
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-5
@@ -1,9 +1,14 @@
|
|||||||
/* eslint-disable no-case-declarations */
|
/* eslint-disable no-case-declarations */
|
||||||
import { AxiosError, AxiosResponse } from "axios";
|
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 { getConfig } from "@app/lib/config/env";
|
||||||
import { request } from "@app/lib/config/request";
|
import { request } from "@app/lib/config/request";
|
||||||
|
import { crypto } from "@app/lib/crypto";
|
||||||
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
import {
|
import {
|
||||||
decryptAppConnectionCredentials,
|
decryptAppConnectionCredentials,
|
||||||
encryptAppConnectionCredentials,
|
encryptAppConnectionCredentials,
|
||||||
@@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums";
|
|||||||
import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums";
|
import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums";
|
||||||
import {
|
import {
|
||||||
ExchangeCodeAzureResponse,
|
ExchangeCodeAzureResponse,
|
||||||
|
TAzureClientSecretsConnectionCertificateCredentials,
|
||||||
TAzureClientSecretsConnectionClientSecretCredentials,
|
TAzureClientSecretsConnectionClientSecretCredentials,
|
||||||
TAzureClientSecretsConnectionConfig,
|
TAzureClientSecretsConnectionConfig,
|
||||||
TAzureClientSecretsConnectionCredentials
|
TAzureClientSecretsConnectionCredentials
|
||||||
} from "./azure-client-secrets-connection-types";
|
} 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 = () => {
|
export const getAzureClientSecretsConnectionListItem = () => {
|
||||||
const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig();
|
const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig();
|
||||||
|
|
||||||
@@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => {
|
|||||||
app: AppConnection.AzureClientSecrets as const,
|
app: AppConnection.AzureClientSecrets as const,
|
||||||
methods: Object.values(AzureClientSecretsConnectionMethod) as [
|
methods: Object.values(AzureClientSecretsConnectionMethod) as [
|
||||||
AzureClientSecretsConnectionMethod.OAuth,
|
AzureClientSecretsConnectionMethod.OAuth,
|
||||||
AzureClientSecretsConnectionMethod.ClientSecret
|
AzureClientSecretsConnectionMethod.ClientSecret,
|
||||||
|
AzureClientSecretsConnectionMethod.Certificate
|
||||||
],
|
],
|
||||||
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID
|
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID
|
||||||
};
|
};
|
||||||
@@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async (
|
|||||||
const { refreshToken } = credentials;
|
const { refreshToken } = credentials;
|
||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
switch (appConnection.method) {
|
switch (appConnection.method) {
|
||||||
case AzureClientSecretsConnectionMethod.OAuth:
|
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||||
if (
|
if (
|
||||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID ||
|
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID ||
|
||||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET
|
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET
|
||||||
@@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async (
|
|||||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
|
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
|
||||||
|
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
}
|
||||||
|
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||||
orgId: appConnection.orgId,
|
orgId: appConnection.orgId,
|
||||||
projectId: appConnection.projectId,
|
projectId: appConnection.projectId,
|
||||||
@@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async (
|
|||||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
||||||
|
|
||||||
return clientData.access_token;
|
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:
|
default:
|
||||||
throw new InternalServerError({
|
throw new InternalServerError({
|
||||||
message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}`
|
message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}`
|
||||||
@@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
|||||||
} = getConfig();
|
} = getConfig();
|
||||||
|
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case AzureClientSecretsConnectionMethod.OAuth:
|
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||||
if (!SITE_URL) {
|
if (!SITE_URL) {
|
||||||
throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" });
|
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,
|
refreshToken: tokenResp.data.refresh_token,
|
||||||
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
|
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||||
const { tenantId, clientId, clientSecret } = inputCredentials;
|
const { tenantId, clientId, clientSecret } = inputCredentials;
|
||||||
try {
|
try {
|
||||||
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
|
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:
|
default:
|
||||||
throw new InternalServerError({
|
throw new InternalServerError({
|
||||||
message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}`
|
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)
|
.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({
|
export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({
|
||||||
clientId: z.string(),
|
clientId: z.string(),
|
||||||
clientSecret: z.string(),
|
clientSecret: z.string(),
|
||||||
@@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z
|
|||||||
expiresAt: z.number()
|
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", [
|
export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||||
z.object({
|
z.object({
|
||||||
method: z
|
method: z
|
||||||
@@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin
|
|||||||
credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe(
|
credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe(
|
||||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
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
|
credentials: z
|
||||||
.union([
|
.union([
|
||||||
AzureClientSecretsConnectionOAuthInputCredentialsSchema,
|
AzureClientSecretsConnectionOAuthInputCredentialsSchema,
|
||||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema
|
AzureClientSecretsConnectionClientSecretInputCredentialsSchema,
|
||||||
|
AzureClientSecretsConnectionCertificateInputCredentialsSchema
|
||||||
])
|
])
|
||||||
.optional()
|
.optional()
|
||||||
.describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials)
|
.describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials)
|
||||||
@@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection(
|
|||||||
z.object({
|
z.object({
|
||||||
method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret),
|
method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret),
|
||||||
credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||||
|
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
@@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion(
|
|||||||
clientId: true,
|
clientId: true,
|
||||||
tenantId: 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 { AppConnection } from "../app-connection-enums";
|
||||||
import {
|
import {
|
||||||
|
AzureClientSecretsConnectionCertificateOutputCredentialsSchema,
|
||||||
AzureClientSecretsConnectionClientSecretOutputCredentialsSchema,
|
AzureClientSecretsConnectionClientSecretOutputCredentialsSchema,
|
||||||
AzureClientSecretsConnectionOAuthOutputCredentialsSchema,
|
AzureClientSecretsConnectionOAuthOutputCredentialsSchema,
|
||||||
AzureClientSecretsConnectionSchema,
|
AzureClientSecretsConnectionSchema,
|
||||||
@@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer<
|
|||||||
typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type TAzureClientSecretsConnectionCertificateCredentials = z.infer<
|
||||||
|
typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export interface ExchangeCodeAzureResponse {
|
export interface ExchangeCodeAzureResponse {
|
||||||
token_type: string;
|
token_type: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -66,29 +66,72 @@ Infisical currently only supports two methods for connecting to Azure, which are
|
|||||||
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Client Secret Authentication">
|
|
||||||
Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work.
|
|
||||||
|
|
||||||
**Prerequisites:**
|
<AccordionGroup>
|
||||||
- An active Azure setup.
|
|
||||||
|
|
||||||
<Steps>
|
<Accordion title="Client Secret Authentication">
|
||||||
<Step title="Assign API permissions to the application">
|
Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work.
|
||||||
For the Azure Client Secrets connection to work, assign the following permissions to your Azure application:
|
|
||||||
|
|
||||||
#### Required API Permissions
|
**Prerequisites:**
|
||||||
|
- An active Azure setup.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Assign API permissions to the application">
|
||||||
|
For the Azure Client Secrets connection to work, assign the following permissions to your Azure application:
|
||||||
|
|
||||||
|
#### Required API Permissions
|
||||||
|
|
||||||
|
**Microsoft Graph**
|
||||||
|
- `Application.ReadWrite.All`
|
||||||
|
- `Application.ReadWrite.OwnedBy`
|
||||||
|
- `Application.ReadWrite.All` (Delegated)
|
||||||
|
- `Directory.ReadWrite.All` (Delegated)
|
||||||
|
- `User.Read` (Delegated)
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="Certificate Authentication">
|
||||||
|
Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work.
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- An active Azure setup.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Assign API permissions to the application">
|
||||||
|
For the Azure Client Secrets connection to work, assign the following permissions to your Azure application:
|
||||||
|
|
||||||
|
#### Required API Permissions
|
||||||
|
|
||||||
|
**Microsoft Graph**
|
||||||
|
- `Application.ReadWrite.All`
|
||||||
|
- `Application.ReadWrite.OwnedBy`
|
||||||
|
- `Application.ReadWrite.All` (Delegated)
|
||||||
|
- `Directory.ReadWrite.All` (Delegated)
|
||||||
|
- `User.Read` (Delegated)
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step title="Upload your certificate to your Azure App Registration">
|
||||||
|
Navigate to the **Certificates & secrets** section of your Azure App Registration, and press the **Upload certificate** button.
|
||||||
|
|
||||||
|
Select the **Upload** button and upload your certificate.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
Keep in mind that both the certificate and its private key are required to configure the Azure Client Secrets connection in Infisical.
|
||||||
|
</Tip>
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
</AccordionGroup>
|
||||||
|
|
||||||
**Microsoft Graph**
|
|
||||||
- `Application.ReadWrite.All`
|
|
||||||
- `Application.ReadWrite.OwnedBy`
|
|
||||||
- `Application.ReadWrite.All` (Delegated)
|
|
||||||
- `Directory.ReadWrite.All` (Delegated)
|
|
||||||
- `User.Read` (Delegated)
|
|
||||||
|
|
||||||

|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
## Setup Azure Connection in Infisical
|
## Setup Azure Connection in Infisical
|
||||||
|
|
||||||
@@ -123,6 +166,17 @@ Infisical currently only supports two methods for connecting to Azure, which are
|
|||||||

|

|
||||||
</Step>
|
</Step>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
<Tab title="Certificate">
|
||||||
|
<Step title="Create Connection">
|
||||||
|
Fill in the **Tenant ID**, **Client ID**, **Certificate (PEM format)**, and **Private Key** fields with the Directory (Tenant) ID, Application (Client) ID, Certificate and Private Key you obtained in the [previous step](#certificate-authentication).
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
The private key is never transmitted to Azure, and it is only used to sign the client assertion used to authenticate with Azure.
|
||||||
|
</Tip>
|
||||||
|
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Step>
|
</Step>
|
||||||
<Step title="Connection Created">
|
<Step title="Connection Created">
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ const syntaxHighlight = (
|
|||||||
isVisible?: boolean,
|
isVisible?: boolean,
|
||||||
isImport?: boolean,
|
isImport?: boolean,
|
||||||
isLoadingValue?: boolean,
|
isLoadingValue?: boolean,
|
||||||
isErrorLoadingValue?: boolean
|
isErrorLoadingValue?: boolean,
|
||||||
|
placeholder?: string
|
||||||
) => {
|
) => {
|
||||||
if (isLoadingValue) return HIDDEN_SECRET_VALUE;
|
if (isLoadingValue) return HIDDEN_SECRET_VALUE;
|
||||||
if (isErrorLoadingValue)
|
if (isErrorLoadingValue)
|
||||||
return <span className="ph-no-capture text-red/75">Error loading secret value.</span>;
|
return <span className="ph-no-capture text-red/75">Error loading secret value.</span>;
|
||||||
if (isImport && !content) return "IMPORTED";
|
if (isImport && !content) return "IMPORTED";
|
||||||
|
if (placeholder && (content === "" || !content)) return placeholder;
|
||||||
if (content === "") return "EMPTY";
|
if (content === "") return "EMPTY";
|
||||||
if (!content) return "EMPTY";
|
if (!content) return "EMPTY";
|
||||||
if (!isVisible) return HIDDEN_SECRET_VALUE;
|
if (!isVisible) return HIDDEN_SECRET_VALUE;
|
||||||
@@ -79,6 +81,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
|||||||
canEditButNotView,
|
canEditButNotView,
|
||||||
isLoadingValue,
|
isLoadingValue,
|
||||||
isErrorLoadingValue,
|
isErrorLoadingValue,
|
||||||
|
placeholder,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -93,18 +96,25 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
|||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<pre aria-hidden className="m-0">
|
<pre aria-hidden className="m-0">
|
||||||
<code className={`inline-block w-full ${commonClassName}`}>
|
<code className={`inline-block w-full ${commonClassName}`}>
|
||||||
<span style={{ whiteSpace: "break-spaces" }}>
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
"whitespace-break-spaces",
|
||||||
|
placeholder && !value && "text-gray-500/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{syntaxHighlight(
|
{syntaxHighlight(
|
||||||
value,
|
value,
|
||||||
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
||||||
isImport,
|
isImport,
|
||||||
isLoadingValue,
|
isLoadingValue,
|
||||||
isErrorLoadingValue
|
isErrorLoadingValue,
|
||||||
|
placeholder
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
<textarea
|
<textarea
|
||||||
|
placeholder={placeholder}
|
||||||
style={{ whiteSpace: "break-spaces" }}
|
style={{ whiteSpace: "break-spaces" }}
|
||||||
aria-label="secret value"
|
aria-label="secret value"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons";
|
import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons";
|
||||||
import {
|
import {
|
||||||
faBullseye,
|
faBullseye,
|
||||||
|
faCertificate,
|
||||||
faKey,
|
faKey,
|
||||||
faLink,
|
faLink,
|
||||||
faLock,
|
faLock,
|
||||||
@@ -211,6 +212,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
|||||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||||
case AzureDevOpsConnectionMethod.ClientSecret:
|
case AzureDevOpsConnectionMethod.ClientSecret:
|
||||||
return { name: "Client Secret", icon: faKey };
|
return { name: "Client Secret", icon: faKey };
|
||||||
|
case AzureClientSecretsConnectionMethod.Certificate:
|
||||||
|
return { name: "Certificate", icon: faCertificate };
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled App Connection Method: ${method}`);
|
throw new Error(`Unhandled App Connection Method: ${method}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con
|
|||||||
|
|
||||||
export enum AzureClientSecretsConnectionMethod {
|
export enum AzureClientSecretsConnectionMethod {
|
||||||
OAuth = "oauth",
|
OAuth = "oauth",
|
||||||
ClientSecret = "client-secret"
|
ClientSecret = "client-secret",
|
||||||
|
Certificate = "certificate"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TAzureClientSecretsConnection = TRootAppConnection & {
|
export type TAzureClientSecretsConnection = TRootAppConnection & {
|
||||||
@@ -24,4 +25,13 @@ export type TAzureClientSecretsConnection = TRootAppConnection & {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
method: AzureClientSecretsConnectionMethod.Certificate;
|
||||||
|
credentials: {
|
||||||
|
clientId: string;
|
||||||
|
tenantId: string;
|
||||||
|
certificateBody: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+103
-4
@@ -6,7 +6,15 @@ import { Controller, FormProvider, useForm } from "react-hook-form";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
ModalClose,
|
||||||
|
SecretInput,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
import {
|
import {
|
||||||
APP_CONNECTION_MAP,
|
APP_CONNECTION_MAP,
|
||||||
getAppConnectionMethodDetails,
|
getAppConnectionMethodDetails,
|
||||||
@@ -27,10 +35,13 @@ import {
|
|||||||
} from "./GenericAppConnectionFields";
|
} from "./GenericAppConnectionFields";
|
||||||
|
|
||||||
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
|
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
|
||||||
|
type CertificateForm = z.infer<typeof certificateSchema>;
|
||||||
|
|
||||||
|
type TInputFormData = ClientSecretForm | CertificateForm;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appConnection?: TAzureClientSecretsConnection;
|
appConnection?: TAzureClientSecretsConnection;
|
||||||
onSubmit: (formData: ClientSecretForm) => Promise<void>;
|
onSubmit: (formData: TInputFormData) => Promise<void>;
|
||||||
projectId: string | undefined | null;
|
projectId: string | undefined | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +64,21 @@ const clientSecretSchema = baseSchema.extend({
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
|
const certificateSchema = baseSchema.extend({
|
||||||
|
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||||
|
credentials: z.object({
|
||||||
|
clientId: z.string().trim().min(1, "Client ID is required"),
|
||||||
|
certificateBody: z.string().trim().min(1, "Certificate is required"),
|
||||||
|
privateKey: z.string().trim().min(1, "Private Key is required"),
|
||||||
|
tenantId: z.string().trim().min(1, "Tenant ID is required")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const formSchema = z.discriminatedUnion("method", [
|
||||||
|
oauthSchema,
|
||||||
|
clientSecretSchema,
|
||||||
|
certificateSchema
|
||||||
|
]);
|
||||||
|
|
||||||
type FormData = z.infer<typeof formSchema>;
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -96,6 +121,20 @@ const getDefaultValues = (appConnection?: TAzureClientSecretsConnection): Partia
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case AzureClientSecretsConnectionMethod.Certificate:
|
||||||
|
if ("clientId" in credentials && "tenantId" in credentials) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
method: AzureClientSecretsConnectionMethod.Certificate,
|
||||||
|
credentials: {
|
||||||
|
clientId: credentials.clientId,
|
||||||
|
tenantId: credentials.tenantId,
|
||||||
|
certificateBody: "",
|
||||||
|
privateKey: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -152,6 +191,9 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
|||||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||||
await onSubmit(formData);
|
await onSubmit(formData);
|
||||||
break;
|
break;
|
||||||
|
case AzureClientSecretsConnectionMethod.Certificate:
|
||||||
|
await onSubmit(formData);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||||
}
|
}
|
||||||
@@ -207,7 +249,11 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="tenantId"
|
name={
|
||||||
|
selectedMethod === AzureClientSecretsConnectionMethod.OAuth
|
||||||
|
? "tenantId"
|
||||||
|
: "credentials.tenantId"
|
||||||
|
}
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -264,6 +310,59 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedMethod === AzureClientSecretsConnectionMethod.Certificate && (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="credentials.clientId"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Client ID"
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="credentials.certificateBody"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Certificate"
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
containerClassName="text-gray-400 group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="-----BEGIN CERTIFICATE-----..."
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="credentials.privateKey"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
label="Private Key"
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<SecretInput
|
||||||
|
placeholder="-----BEGIN PRIVATE KEY-----..."
|
||||||
|
containerClassName="text-gray-400 group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="mt-8 flex items-center">
|
<div className="mt-8 flex items-center">
|
||||||
<Button
|
<Button
|
||||||
className="mr-4"
|
className="mr-4"
|
||||||
|
|||||||
Reference in New Issue
Block a user