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.", 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.",
@@ -1,4 +1,5 @@
export enum AzureClientSecretsConnectionMethod { export enum AzureClientSecretsConnectionMethod {
OAuth = "oauth", OAuth = "oauth",
ClientSecret = "client-secret" ClientSecret = "client-secret",
Certificate = "certificate"
} }
@@ -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}`
@@ -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
})
}) })
]); ]);
@@ -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)
![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png)
</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)
![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png)
</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.
![Upload certificate](/images/app-connections/azure/client-secrets/upload-certificate.png)
<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)
![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png)
</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
![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-client-secrets-method.png) ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-client-secrets-method.png)
</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>
![Connect via Azure Certificate](/images/app-connections/azure/client-secrets/create-certificate-method.png)
</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}
+3
View File
@@ -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;
};
}
); );
@@ -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"