feat(azure): add Deploy-to-Azure Bicep template and certificate auth

This commit is contained in:
Lydia Vilchez committed 2026-08-12 09:48:34 +02:00
1 parent d41b2eaa0f
commit ab738e8a4e
34 files changed
+1953 -93

No files matched your search

@@ -0,0 +1 @@
Add a Deploy to Azure quick-start to the add-provider wizard. Azure now shows a credential-type selector (Certificate authentication recommended, Service Principal with Client Secret fallback), and the certificate form exposes a one-click button that opens the Azure Portal deployment blade pre-loaded with the Prowler Bicep template (App Registration + Service Principal + Reader + custom ProwlerRole + certificate credential)
@@ -2,6 +2,7 @@
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaAzure } from "@/components/providers/workflow/forms/select-credentials-type/azure";
import { SelectViaCloudflare } from "@/components/providers/workflow/forms/select-credentials-type/cloudflare";
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
@@ -21,6 +22,9 @@ export const CredentialsUpdateInfo = ({
if (providerType === "aws") {
return <SelectViaAWS initialVia={initialVia} />;
}
if (providerType === "azure") {
return <SelectViaAzure initialVia={initialVia} />;
}
if (providerType === "gcp") {
return <SelectViaGCP initialVia={initialVia} />;
}
@@ -17,6 +17,10 @@ vi.mock("../../workflow/forms/select-credentials-type/aws", () => ({
SelectViaAWS: () => <div>select-via-aws</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/azure", () => ({
SelectViaAzure: () => <div>select-via-azure</div>,
}));
vi.mock("../../workflow/forms/select-credentials-type/alibabacloud", () => ({
SelectViaAlibabaCloud: () => <div>select-via-alibabacloud</div>,
}));
@@ -14,6 +14,7 @@ import {
} from "../../workflow/forms";
import { SelectViaAlibabaCloud } from "../../workflow/forms/select-credentials-type/alibabacloud";
import { SelectViaAWS } from "../../workflow/forms/select-credentials-type/aws";
import { SelectViaAzure } from "../../workflow/forms/select-credentials-type/azure";
import { SelectViaCloudflare } from "../../workflow/forms/select-credentials-type/cloudflare";
import {
AddViaServiceAccountForm,
@@ -131,6 +132,14 @@ export function CredentialsStep({
/>
);
}
if (providerType === "azure") {
return (
<SelectViaAzure
initialVia={via || undefined}
onViaChange={handleViaChange}
/>
);
}
if (providerType === "gcp") {
return (
<SelectViaGCP
@@ -17,7 +17,8 @@ import {
ApiResponse,
AWSCredentials,
AWSCredentialsRole,
AzureCredentials,
AzureCertificateCredentials,
AzureClientSecretCredentials,
CloudflareApiKeyCredentials,
CloudflareTokenCredentials,
GCPDefaultCredentials,
@@ -44,6 +45,10 @@ import {
} from "./select-credentials-type/alibabacloud/credentials-type";
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form";
import {
AzureCertificateCredentialsForm,
AzureServicePrincipalCredentialsForm,
} from "./select-credentials-type/azure";
import {
CloudflareApiKeyCredentialsForm,
CloudflareApiTokenCredentialsForm,
@@ -54,7 +59,6 @@ import {
M365CertificateCredentialsForm,
M365ClientSecretCredentialsForm,
} from "./select-credentials-type/m365";
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form";
import { GoogleWorkspaceCredentialsForm } from "./via-credentials/googleworkspace-credentials-form";
import { IacCredentialsForm } from "./via-credentials/iac-credentials-form";
@@ -175,9 +179,18 @@ export const BaseCredentialsForm = ({
control={form.control as unknown as Control<AWSCredentials>}
/>
)}
{providerType === "azure" && (
<AzureCredentialsForm
control={form.control as unknown as Control<AzureCredentials>}
{providerType === "azure" && effectiveVia === "app_client_secret" && (
<AzureServicePrincipalCredentialsForm
control={
form.control as unknown as Control<AzureClientSecretCredentials>
}
/>
)}
{providerType === "azure" && effectiveVia === "app_certificate" && (
<AzureCertificateCredentialsForm
control={
form.control as unknown as Control<AzureCertificateCredentials>
}
/>
)}
{providerType === "m365" && effectiveVia === "app_client_secret" && (
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { AzureCertificateCredentials } from "@/types";
import { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
// Wraps the form in a react-hook-form context so the WizardInputField and
// WizardTextareaField mount without exploding. We are testing the Deploy to
// Azure link and the field structure, not the input controls themselves.
const Harness = () => {
const form = useForm<AzureCertificateCredentials>();
return (
<FormProvider {...form}>
<AzureCertificateCredentialsForm control={form.control} />
</FormProvider>
);
};
describe("AzureCertificateCredentialsForm", () => {
it("renders the Deploy to Azure link with the Portal custom-deployment URL and safe target attributes", () => {
// Given
render(<Harness />);
// When
const link = screen.getByRole("link", { name: /deploy to azure/i });
// Then
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
const href = link.getAttribute("href") ?? "";
expect(
href.startsWith(
"https://portal.azure.com/#create/Microsoft.Template/uri/",
),
).toBe(true);
// The Bicep template URL must be percent-encoded exactly once into the
// fragment; asserting on the encoded slashes guards against a future
// refactor that swaps `encodeURIComponent` for a helper that leaves them
// raw and breaks the Portal parser.
expect(href).toContain(
"https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
);
});
it("keeps the certificate generation docs link pointing at the Azure authentication doc", () => {
// Given
render(<Harness />);
// When
const docsLink = screen.getByRole("link", {
name: /certificate generation guide/i,
});
// Then
expect(docsLink).toHaveAttribute(
"href",
"https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication",
);
expect(docsLink).toHaveAttribute("target", "_blank");
expect(docsLink).toHaveAttribute("rel", "noopener noreferrer");
});
});
@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { Control } from "react-hook-form";
import {
WizardInputField,
WizardTextareaField,
} from "@/components/providers/workflow/forms/fields";
import { Button } from "@/components/shadcn";
import { getAzureDeploymentQuickLink } from "@/lib/external-urls";
import { AzureCertificateCredentials } from "@/types";
export const AzureCertificateCredentialsForm = ({
control,
}: {
control: Control<AzureCertificateCredentials>;
}) => {
// The Deploy-to-Azure link is a plain constant today: unlike the AWS
// organisation link, it does not need any wizard-collected value in the
// URL (subscription selection happens inside the Portal deployment blade).
// If Prowler ever needs to pin the deployment to a specific subscription
// or region, thread that value through `getAzureDeploymentQuickLink` here.
const deployToAzureUrl = getAzureDeploymentQuickLink();
return (
<>
<div className="flex flex-col">
<div className="text-md text-text-neutral-primary leading-9 font-bold">
Certificate Authentication (Recommended)
</div>
<div className="text-text-neutral-tertiary text-sm">
Deploy the Prowler Bicep template to provision the App Registration,
Service Principal, and read-only roles in one click, then paste the
resulting credentials below.
</div>
</div>
<div className="flex flex-col items-start gap-1">
<Button variant="link" size="link-sm" asChild>
<a href={deployToAzureUrl} target="_blank" rel="noopener noreferrer">
Deploy to Azure
</a>
</Button>
<p className="text-text-neutral-tertiary text-xs">
After deployment, copy <strong>Tenant ID</strong> and{" "}
<strong>Application (Client) ID</strong> from the deployment outputs —
the certificate private key you generated locally goes in the
&ldquo;Certificate Private Key&rdquo; field below.
</p>
</div>
<WizardInputField
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
/>
<WizardInputField
control={control}
name="client_id"
type="text"
label="Client ID"
labelPlacement="inside"
placeholder="Enter the Client ID"
variant="bordered"
isRequired
/>
<WizardTextareaField
control={control}
name="certificate_content"
label="Certificate Private Key (Base64)"
labelPlacement="inside"
placeholder="Paste the base64-encoded private key that pairs with the certificate deployed to Entra ID"
variant="bordered"
isRequired
minRows={4}
/>
<p className="text-text-neutral-tertiary text-sm">
This is the <strong>base64-encoded private key</strong> that matches the
certificate uploaded to Entra ID by the Bicep template (not the public
certificate, and not the thumbprint). Generation instructions (openssl /
PowerShell) are in the{" "}
<Link
href="https://docs.prowler.com/user-guide/providers/azure/authentication#certificate-authentication"
target="_blank"
rel="noopener noreferrer"
className="text-button-tertiary p-0 text-sm"
>
certificate generation guide
</Link>
.
</p>
</>
);
};
@@ -1,23 +1,37 @@
"use client";
import { Control } from "react-hook-form";
import { WizardInputField } from "@/components/providers/workflow/forms/fields";
import { AzureCredentials } from "@/types";
import { AzureClientSecretCredentials } from "@/types";
export const AzureCredentialsForm = ({
export const AzureServicePrincipalCredentialsForm = ({
control,
}: {
control: Control<AzureCredentials>;
control: Control<AzureClientSecretCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-text-neutral-primary leading-9 font-bold">
Connect via Credentials
Service Principal with Client Secret
</div>
<div className="text-text-neutral-tertiary text-sm">
Please provide the information for your Azure credentials.
Please provide the Azure Service Principal credentials issued from
your App Registration&apos;s <em>Certificates &amp; secrets</em>{" "}
blade.
</div>
</div>
<WizardInputField
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
/>
<WizardInputField
control={control}
name="client_id"
@@ -38,16 +52,6 @@ export const AzureCredentialsForm = ({
variant="bordered"
isRequired
/>
<WizardInputField
control={control}
name="tenant_id"
type="text"
label="Tenant ID"
labelPlacement="inside"
placeholder="Enter the Tenant ID"
variant="bordered"
isRequired
/>
</>
);
};
@@ -0,0 +1,2 @@
export { AzureCertificateCredentialsForm } from "./azure-certificate-credentials-form";
export { AzureServicePrincipalCredentialsForm } from "./azure-service-principal-credentials-form";
@@ -0,0 +1,5 @@
export {
AzureCertificateCredentialsForm,
AzureServicePrincipalCredentialsForm,
} from "./credentials-type";
export { SelectViaAzure } from "./select-via-azure";
@@ -0,0 +1,58 @@
"use client";
import { Control, Controller } from "react-hook-form";
import { WizardRadioCard } from "@/components/providers/workflow/forms/fields";
import { FormMessage } from "@/components/shadcn/form";
import { RadioGroup } from "@/components/shadcn/radio-group/radio-group";
type RadioGroupAzureViaCredentialsFormProps = {
control: Control<any>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
};
// The via values (`app_client_secret` / `app_certificate`) mirror M365 on
// purpose: both providers authenticate against an Entra ID App Registration
// and reuse the same wizard state machine + per-method docs anchors.
export const RadioGroupAzureViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
onChange,
}: RadioGroupAzureViaCredentialsFormProps) => {
return (
<Controller
name="azureCredentialsType"
control={control}
render={({ field }) => (
<>
<RadioGroup
name={field.name}
value={field.value || ""}
onValueChange={(value: string) => {
field.onChange(value);
onChange?.(value);
}}
>
<span className="text-text-neutral-tertiary text-sm">
Select Authentication Method
</span>
<WizardRadioCard value="app_certificate" isInvalid={isInvalid}>
Certificate Authentication (Recommended)
</WizardRadioCard>
<WizardRadioCard value="app_client_secret" isInvalid={isInvalid}>
Service Principal with Client Secret
</WizardRadioCard>
</RadioGroup>
{errorMessage && (
<FormMessage className="text-text-error-primary">
{errorMessage}
</FormMessage>
)}
</>
)}
/>
);
};
@@ -0,0 +1,47 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form } from "@/components/shadcn/form";
import { RadioGroupAzureViaCredentialsTypeForm } from "./radio-group-azure-via-credentials-type-form";
interface SelectViaAzureProps {
initialVia?: string;
onViaChange?: (via: string) => void;
}
export const SelectViaAzure = ({
initialVia,
onViaChange,
}: SelectViaAzureProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
azureCredentialsType: initialVia || "",
},
});
const handleSelectionChange = (value: string) => {
if (onViaChange) {
onViaChange(value);
return;
}
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());
};
return (
<Form {...form}>
<RadioGroupAzureViaCredentialsTypeForm
control={form.control}
isInvalid={!!form.formState.errors.azureCredentialsType}
errorMessage={form.formState.errors.azureCredentialsType?.message}
onChange={handleSelectionChange}
/>
</Form>
);
};
@@ -1,4 +1,3 @@
export * from "./azure-credentials-form";
export * from "./github-credentials-form";
export * from "./iac-credentials-form";
export * from "./image-credentials-form";
+16 -1
View File
@@ -55,8 +55,10 @@ export const useCredentialsForm = ({
if (providerType === "gcp" && effectiveVia === "service-account") {
return addCredentialsServiceAccountFormSchema(providerType);
}
// For GitHub, M365, and Cloudflare, we need to pass the via parameter to determine which fields are required
// For Azure, GitHub, M365, and Cloudflare, we need to pass the via
// parameter to determine which fields are required
if (
providerType === "azure" ||
providerType === "github" ||
providerType === "m365" ||
providerType === "cloudflare"
@@ -111,6 +113,19 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.AWS_SESSION_TOKEN]: "",
};
case "azure":
// Azure now mirrors the M365 flow: the user picks the auth method
// in the selector step, which sets `via`, and we only seed the
// fields that method actually shows. Missing fields would still be
// registered by the schema as optional, but seeding them here keeps
// the react-hook-form store in sync with the visible form.
if (effectiveVia === "app_certificate") {
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
[ProviderCredentialFields.CERTIFICATE_CONTENT]: "",
[ProviderCredentialFields.TENANT_ID]: "",
};
}
return {
...baseDefaults,
[ProviderCredentialFields.CLIENT_ID]: "",
+55
View File
@@ -10,8 +10,10 @@ import {
buildGitHubPersonalAccessTokenOrgUrl,
getAWSCredentialsTemplateLinks,
getAWSOrgDeploymentQuickLink,
getAzureDeploymentQuickLink,
PRECONFIGURED_CREDENTIAL_URLS,
getProviderHelpText,
PROWLER_AZURE_BICEP_TEMPLATE_URL,
PROWLER_CF_TEMPLATE_URL,
} from "./external-urls";
@@ -285,6 +287,59 @@ describe("buildGitHubPersonalAccessTokenOrgUrl", () => {
});
});
describe("getAzureDeploymentQuickLink", () => {
it("pins the Bicep template to the public S3 bucket that hosts the Prowler templates", () => {
// Snapshot check: fixes the exact template URL so a stray edit to the
// bucket, path, or filename trips a failing test instead of silently
// pointing the Deploy to Azure button at a 404. Kept in sync with the
// Bicep file at `permissions/templates/azure/bicep/prowler-scan.json`
// and its README, plus the actual object hosted in S3.
expect(PROWLER_AZURE_BICEP_TEMPLATE_URL).toBe(
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json",
);
});
it("routes through the Azure Portal custom-deployment URL fragment with the template URL percent-encoded exactly once", () => {
// The Portal SPA re-parses the hash fragment and URL-decodes the
// `uri/<value>` segment once, so double-encoding would land the user on
// a broken page. Asserting on the raw substring guards against a future
// refactor that swaps `encodeURIComponent` for `URLSearchParams`, which
// would encode differently (e.g. spaces as `+`).
const url = getAzureDeploymentQuickLink();
expect(url).toBe(
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
PROWLER_AZURE_BICEP_TEMPLATE_URL,
)}`,
);
expect(url).toContain(
"https%3A%2F%2Fprowler-cloud-public.s3.eu-west-1.amazonaws.com%2Fpermissions%2Ftemplates%2Fazure%2Fbicep%2Fprowler-scan.json",
);
});
it("accepts a custom template URL so operators can point the button at a private mirror without editing the constant", () => {
// Self-hosted or air-gapped installs occasionally re-host the template
// on their own bucket for compliance reasons. The helper accepts an
// override so those callers don't have to reimplement the fragment
// encoding themselves.
const customUrl =
"https://example.internal/templates/prowler-scan.json?v=2";
const url = getAzureDeploymentQuickLink(customUrl);
expect(url).toBe(
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(
customUrl,
)}`,
);
// The `?` inside the query-string of the custom URL must be encoded so
// the Portal parser reads the entire value as the template URL instead
// of truncating at the first `?`.
expect(url).toContain(
"uri/https%3A%2F%2Fexample.internal%2Ftemplates%2Fprowler-scan.json%3Fv%3D2",
);
});
});
describe("getProviderHelpText", () => {
const AWS_SHORTLINK = "https://goto.prowler.com/provider-aws";
const AWS_CREDENTIALS_STEP_DOCS =
+38
View File
@@ -40,6 +40,19 @@ export const getAttackPathHubUrl = (queryId: string): string =>
export const PROWLER_CF_TEMPLATE_URL =
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/aws/cloudformation/prowler-scan-role.yml";
// ARM JSON template URL for the Prowler quick-start Azure deployment (App
// Registration + Service Principal + Reader + custom ProwlerRole + certificate
// credential). The template is authored in Bicep at
// `permissions/templates/azure/bicep/prowler-scan.bicep` and compiled to ARM
// JSON at `prowler-scan.json` — the Azure Portal `#create/Microsoft.Template`
// deep link only accepts ARM JSON, not raw Bicep source. Regenerate with
// `make -C permissions/templates/azure/bicep build` after editing the .bicep.
// URL-encoded and appended to the Portal deep link built by
// `getAzureDeploymentQuickLink` below. Points at the same public S3 bucket
// that hosts the AWS templates.
export const PROWLER_AZURE_BICEP_TEMPLATE_URL =
"https://prowler-cloud-public.s3.eu-west-1.amazonaws.com/permissions/templates/azure/bicep/prowler-scan.json";
// Prowler Cloud billing/subscription management page.
export const BILLING_URL = "https://cloud.prowler.com/billing";
@@ -215,6 +228,14 @@ const PROVIDER_CREDENTIALS_METHOD_DOCS_URL: Record<
credentials:
"https://docs.prowler.com/user-guide/providers/aws/getting-started-aws#credentials-static-access-keys",
},
azure: {
// The Deploy-to-Azure certificate flow is the recommended path;
// client-secret authentication remains the manual fallback.
app_certificate:
"https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#certificate-authentication-recommended",
app_client_secret:
"https://docs.prowler.com/user-guide/providers/azure/getting-started-azure#service-principal-with-client-secret",
},
m365: {
app_certificate:
"https://docs.prowler.com/user-guide/providers/microsoft365/getting-started-m365#application-certificate-authentication-recommended",
@@ -393,3 +414,20 @@ export const getAWSOrgDeploymentQuickLink = ({
return buildCloudFormationQuickCreateLink(parameters);
};
// Builds the Azure Portal deep link that opens the "Custom deployment" wizard
// pre-loaded with the Prowler Bicep template. The Portal expects
// `#create/Microsoft.Template/uri/<double-encoded-template-url>` — the URL
// fragment is re-parsed by the Portal SPA which URL-decodes the segment once
// more, so the template URL must be percent-encoded exactly once via
// `encodeURIComponent`. This is the Azure analogue of the AWS CloudFormation
// quick-create link built by `getAWSOrgDeploymentQuickLink` above.
//
// The resulting URL is safe to open in a new tab; the user is expected to be
// signed into the Azure Portal already. If they are not, the Portal will
// bounce them through the standard sign-in flow before landing on the
// deployment form with the template still pre-loaded.
export const getAzureDeploymentQuickLink = (
templateUrl: string = PROWLER_AZURE_BICEP_TEMPLATE_URL,
): string =>
`https://portal.azure.com/#create/Microsoft.Template/uri/${encodeURIComponent(templateUrl)}`;
+12
View File
@@ -87,6 +87,7 @@ export const getProviderFormType = (
// Providers that need credential type selection
const needsSelector = [
"aws",
"azure",
"gcp",
"github",
"m365",
@@ -119,6 +120,17 @@ export const getProviderFormType = (
return "credentials";
}
// Azure credential types — shares the M365 `app_client_secret` /
// `app_certificate` via values because both providers authenticate against
// an Entra ID App Registration; keeping the via strings identical lets the
// per-method docs URLs and the wizard state machine reuse the same logic.
if (
providerType === "azure" &&
["app_client_secret", "app_certificate"].includes(via || "")
) {
return "credentials";
}
// M365 credential types
if (
providerType === "m365" &&
+16 -1
View File
@@ -219,13 +219,28 @@ export type AWSCredentialsRole = {
[ProviderCredentialFields.CREDENTIALS_TYPE]?: AWSCredentialsType;
};
export type AzureCredentials = {
export type AzureClientSecretCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CLIENT_SECRET]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type AzureCertificateCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CERTIFICATE_CONTENT]: string;
[ProviderCredentialFields.TENANT_ID]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
// `AzureCredentials` used to be the client-secret-only shape. It now spans
// both service-principal auth methods so callers that render either form can
// keep the same react-hook-form Control<>. The two forms share `client_id`
// and `tenant_id`, so consumers touching only those fields need no changes.
export type AzureCredentials =
| AzureClientSecretCredentials
| AzureCertificateCredentials;
export type M365ClientSecretCredentials = {
[ProviderCredentialFields.CLIENT_ID]: string;
[ProviderCredentialFields.CLIENT_SECRET]: string;
+39 -2
View File
@@ -211,12 +211,19 @@ export const addCredentialsFormSchema = (
}
: providerType === "azure"
? {
// Client secret vs. certificate is a per-form choice driven by
// `via`; the field-level presence check runs inside the
// credential-type form (see azure-*-credentials-form.tsx). The
// schema keeps both optional so switching methods without a
// page reload does not trigger a stale "required" error on the
// field that is not shown.
[ProviderCredentialFields.CLIENT_ID]: z
.string()
.min(1, "Client ID is required"),
[ProviderCredentialFields.CLIENT_SECRET]: z
[ProviderCredentialFields.CLIENT_SECRET]: z.string().optional(),
[ProviderCredentialFields.CERTIFICATE_CONTENT]: z
.string()
.min(1, "Client Secret is required"),
.optional(),
[ProviderCredentialFields.TENANT_ID]: z
.string()
.min(1, "Tenant ID is required"),
@@ -442,6 +449,36 @@ export const addCredentialsFormSchema = (
: {}),
})
.superRefine((data: Record<string, string | undefined>, ctx) => {
if (providerType === "azure") {
// Azure schema keeps both `client_secret` and `certificate_content`
// optional at field level (the credential-type selector picks which
// form is shown). The visible field for the chosen `via` is what
// the user must fill — enforce it here so the client catches empty
// submissions before hitting the API, mirroring M365. Error copy is
// aligned with the visible field labels ("Certificate Private Key"
// vs. the technical `certificate_content` field name).
if (via === "app_client_secret") {
const clientSecret = data[ProviderCredentialFields.CLIENT_SECRET];
if (!clientSecret || clientSecret.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Client Secret is required",
path: [ProviderCredentialFields.CLIENT_SECRET],
});
}
} else if (via === "app_certificate") {
const certificateContent =
data[ProviderCredentialFields.CERTIFICATE_CONTENT];
if (!certificateContent || certificateContent.trim() === "") {
ctx.addIssue({
code: "custom",
message: "Certificate Private Key is required",
path: [ProviderCredentialFields.CERTIFICATE_CONTENT],
});
}
}
}
if (providerType === "m365") {
// Validate based on the via parameter
if (via === "app_client_secret") {