feat(app-connections/azure-client-secrets): certificate auth

This commit is contained in:
Daniel Hougaard
2025-11-06 06:24:56 +04:00
parent dab4c35ec9
commit 32587a3c99
9 changed files with 369 additions and 15 deletions
+4 -1
View File
@@ -2308,7 +2308,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.",
certificate: "The certificate to use to connect with Azure Client Secrets.",
privateKey:
"The private key to use to connect with Azure Client Secrets. This is never transmitted to Azure and is only used to sign the Azure client assertion with."
}, },
AZURE_DEVOPS: { 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,13 @@
/* 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 { 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 +21,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(/-----BEGIN CERTIFICATE-----/, "")
.replace(/-----END CERTIFICATE-----/, "")
.replace(/\s/g, ""),
"base64"
);
// thumbprint of the certificate is used for the jwt header
const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex");
const x5t = Buffer.from(thumbprint, "hex").toString("base64url");
// JWT Header
const header = {
alg: "RS256",
typ: "JWT",
x5t
};
const now = Math.floor(Date.now() / 1000);
const payload = {
aud: tokenEndpoint,
exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself)
iss: clientId,
jti: uuidv4(), // random ID for the JWT
nbf: now, // not before the jwt is valid
sub: clientId
};
// encode header and payload
const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signatureInput = `${encodedHeader}.${encodedPayload}`;
let keyObject: KeyObject;
try {
if (privateKey.includes("BEGIN PRIVATE KEY")) {
keyObject = crypto.nativeCrypto.createPrivateKey(privateKey);
} else {
// if user forgot to wrap in begin/end private key, decode and use as der format
keyObject = crypto.nativeCrypto.createPrivateKey({
key: Buffer.from(privateKey, "base64"),
format: "der",
type: "pkcs8"
});
}
} catch (error) {
throw new BadRequestError({
message: "Invalid private key format provided. Expected PEM format private key."
});
}
// sign with private key
const signer = crypto.nativeCrypto.createSign("RSA-SHA256");
signer.update(signatureInput);
signer.end();
const signature = signer.sign(keyObject, "base64url");
return `${signatureInput}.${signature}`;
};
export const getAzureClientSecretsConnectionListItem = () => { 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 +105,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 +140,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 +177,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 +216,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, certificate, privateKey } = accessTokenCredentials;
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
return accessToken;
}
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificate);
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 +277,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 +342,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 +377,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
}); });
} }
} }
}
case AzureClientSecretsConnectionMethod.Certificate: {
const { tenantId, certificate, privateKey, clientId } = inputCredentials;
try {
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificate);
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const params = new URLSearchParams({
client_id: clientId,
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: clientAssertion,
scope: "https://graph.microsoft.com/.default",
grant_type: "client_credentials"
});
const response = await request.post<ExchangeCodeAzureResponse>(tokenEndpoint, params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
return {
tenantId,
clientId,
certificate,
privateKey,
accessToken: response.data.access_token,
expiresAt: Date.now() + response.data.expires_in * 1000
};
} catch (e: unknown) {
if (e instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(e?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else if (e instanceof BadRequestError) {
throw e;
} else {
logger.error(
e,
"validateAzureClientSecretsConnectionCredentials: Failed to get access token using certificate authentication"
);
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
}
default: 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),
certificate: z
.string()
.trim()
.min(1, "Certificate required")
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificate),
privateKey: z
.string()
.trim()
.min(1, "Private Key required")
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey)
});
export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({ 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(),
certificate: 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;
@@ -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;
certificate: 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"),
certificate: 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,
certificate: "",
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}`);
} }
@@ -264,6 +306,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.certificate"
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"