diff --git a/messages/en-US.json b/messages/en-US.json
index 6cb69d057..31b653467 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -933,6 +933,9 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured",
+ "policyAuthMtlsTitle": "Client Certificate (mTLS)",
+ "policyAuthMtlsDescription": "Require a client certificate signed by a trusted CA during the TLS handshake",
+ "policyAuthMtlsSummary": "{count} trusted CA certificates",
"policyAuthHeaderName": "Username",
"policyAuthHeaderValue": "Password",
"policyAuthSetPasscode": "Set Passcode",
@@ -3939,7 +3942,7 @@
"backToHome": "Go back to home",
"needToSignInToOrg": "Need to use your organization's identity provider?",
"mtls": "mTLS",
- "mtlsCaCertificates": "Trusted CA certificates",
+ "mtlsCaCertificates": "Set Client Certificates",
"mtlsCaCertificatesDescription": "Clients must present a certificate signed by one of these certificate authorities to connect. Upload public CA certificates only, never a private key.",
"mtlsCertificateName": "Name (optional)",
"mtlsCertificateNamePlaceholder": "Corporate device CA",
@@ -3956,7 +3959,6 @@
"mtlsCertificateErrorRemoveDescription": "The certificate could not be removed.",
"mtlsLastCertificateRemoved": "mTLS was turned off because the last CA certificate was removed.",
"mtlsNoCertificates": "No CA certificates added yet",
- "mtlsSummary": "{count} trusted CA certificates",
"mtlsRemoveCertificate": "Remove CA certificate",
"mtlsExpired": "Expired",
"mtlsExpiresSoon": "Expires soon",
diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx
index ed04e2e1d..29c2f4825 100644
--- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx
@@ -1,14 +1,7 @@
"use client";
-import { ResourceMtlsSection } from "@app/components/ResourceMtlsSection";
import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm";
-import { SettingsContainer } from "@app/components/Settings";
export default function ResourceAuthenticationPage() {
- return (
-
-
-
-
- );
+ return ;
}
diff --git a/src/components/ResourceMtlsSection.tsx b/src/components/ResourceMtlsSection.tsx
deleted file mode 100644
index d139c6d58..000000000
--- a/src/components/ResourceMtlsSection.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client";
-
-import { PolicyAuthMethodRow } from "@app/components/resource-policy/PolicyAuthMethodRow";
-import { ResourceMtlsCertificatesCredenza } from "@app/components/ResourceMtlsCertificatesCredenza";
-import {
- SettingsSection,
- SettingsSectionBody,
- SettingsSectionDescription,
- SettingsSectionFooter,
- SettingsSectionHeader,
- SettingsSectionTitle
-} from "@app/components/Settings";
-import { Alert, AlertDescription } from "@app/components/ui/alert";
-import { Button } from "@app/components/ui/button";
-import { useEnvContext } from "@app/hooks/useEnvContext";
-import { useResourceContext } from "@app/hooks/useResourceContext";
-import { toast } from "@app/hooks/useToast";
-import { createApiClient, formatAxiosError } from "@app/lib/api";
-import { resourceQueries } from "@app/lib/queries";
-import type { UpdateResourceResponse } from "@server/routers/resource";
-import { useQuery } from "@tanstack/react-query";
-import type { AxiosResponse } from "axios";
-import { AlertCircle } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useEffect, useState } from "react";
-
-export function ResourceMtlsSection() {
- const t = useTranslations();
- const { env } = useEnvContext();
- const { resource, updateResource } = useResourceContext();
- const api = createApiClient({ env });
-
- const supportsMtls =
- !env.flags.disableEnterpriseFeatures && resource.mode === "http";
-
- const { data: certificates = [], isLoading: isLoadingCertificates } =
- useQuery({
- ...resourceQueries.resourceMtlsCertificates({
- resourceId: resource.resourceId
- }),
- enabled: supportsMtls
- });
-
- const [mtlsEnabled, setMtlsEnabled] = useState(resource.mtlsEnabled);
- const [certificatesOpen, setCertificatesOpen] = useState(false);
- const [saving, setSaving] = useState(false);
-
- // The server switches mTLS off when the last CA is removed.
- useEffect(() => {
- setMtlsEnabled(resource.mtlsEnabled);
- }, [resource.mtlsEnabled]);
-
- if (!supportsMtls) {
- return null;
- }
-
- const hasCertificates = certificates.length > 0;
-
- function onToggle(active: boolean) {
- setMtlsEnabled(active);
- setCertificatesOpen(active);
- }
-
- function onCertificatesOpenChange(open: boolean) {
- setCertificatesOpen(open);
- // mTLS can't be enabled without a trusted CA.
- if (!open && !hasCertificates) {
- setMtlsEnabled(false);
- }
- }
-
- async function onSave() {
- setSaving(true);
- try {
- await api.post>(
- `/resource/${resource.resourceId}`,
- { mtlsEnabled }
- );
- updateResource({ mtlsEnabled });
- toast({
- title: t("resourceUpdated"),
- description: t("resourceUpdatedDescription")
- });
- } catch (e) {
- toast({
- variant: "destructive",
- title: t("resourceErrorUpdate"),
- description: formatAxiosError(
- e,
- t("resourceErrorUpdateDescription")
- )
- });
- } finally {
- setSaving(false);
- }
- }
-
- return (
-
-
- {t("mtlsEnforcement")}
-
- {t("mtlsEnforcementDescription")}
-
-
-
-
- {!resource.ssl && (
-
-
-
- {t("mtlsRequiresSsl")}
-
-
- )}
-
setCertificatesOpen(true)}
- onToggle={onToggle}
- disabled={!resource.ssl}
- />
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx b/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx
index 8b621416f..c0aa4ee84 100644
--- a/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx
+++ b/src/components/resource-policy/PolicyAuthOtherMethodsSection.tsx
@@ -13,6 +13,7 @@ import type { PolicyAuthMethodId } from "./policy-auth-method-id";
import {
getEmailWhitelistSummary,
getHeaderAuthSummary,
+ getMtlsSummary,
getPasscodeSummary,
getPincodeSummary
} from "./policy-auth-summaries";
@@ -31,6 +32,14 @@ export type PolicyAuthOtherMethodsSectionProps = {
onTogglePasscode: (active: boolean) => void;
onToggleEmail: (active: boolean) => void;
onToggleHeaderAuth: (active: boolean) => void;
+ mtls?: {
+ active: boolean;
+ certificateCount: number;
+ sslEnabled: boolean;
+ disabled?: boolean;
+ onConfigure: () => void;
+ onToggle: (active: boolean) => void;
+ };
};
export function PolicyAuthOtherMethodsSection({
@@ -46,7 +55,8 @@ export function PolicyAuthOtherMethodsSection({
onTogglePincode,
onTogglePasscode,
onToggleEmail,
- onToggleHeaderAuth
+ onToggleHeaderAuth,
+ mtls
}: PolicyAuthOtherMethodsSectionProps) {
const t = useTranslations();
@@ -113,6 +123,26 @@ export function PolicyAuthOtherMethodsSection({
onToggle={onToggleHeaderAuth}
disabled={disabled}
/>
+
+ {mtls && (
+
+ )}
diff --git a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
index 4a4734200..42b546978 100644
--- a/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
+++ b/src/components/resource-policy/PolicyAuthStackSectionEdit.tsx
@@ -14,10 +14,12 @@ import {
type SelectedRole
} from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
+import { ResourceMtlsCertificatesCredenza } from "@app/components/ResourceMtlsCertificatesCredenza";
import { Button } from "@app/components/ui/button";
import { Form, FormField } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useResourceMtls } from "@app/hooks/useResourceMtls";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { resourceQueries } from "@app/lib/queries";
@@ -258,6 +260,12 @@ export function PolicyAuthStackSectionEdit({
const [editingMethod, setEditingMethod] =
useState(null);
+ const mtls = useResourceMtls();
+ // mTLS is resource-level, so it stays editable when the policy is shared.
+ const mtlsReadonly = readonly;
+ const canSaveMtls =
+ mtls.isDirty && !(mtls.enabled && !mtls.hasCertificates);
+
const sso = useWatch({ control: form.control, name: "sso" });
const skipToIdpId = useWatch({
control: form.control,
@@ -414,6 +422,10 @@ export function PolicyAuthStackSectionEdit({
.catch(handleError)
);
policyUpdates.emailWhitelistEnabled = payload.emailWhitelistEnabled;
+
+ if (canSaveMtls) {
+ requests.push(mtls.save().catch(handleError));
+ }
}
try {
@@ -488,7 +500,8 @@ export function PolicyAuthStackSectionEdit({
}),
api.post(`/resource/${resourceId}/users`, {
userIds: currentResourceUserIds
- })
+ }),
+ ...(canSaveMtls ? [mtls.save()] : [])
]);
toast({
@@ -692,6 +705,32 @@ export function PolicyAuthStackSectionEdit({
}
)
}
+ mtls={
+ mtls.supported
+ ? {
+ active: mtls.enabled,
+ certificateCount:
+ mtls.certificates.length,
+ sslEnabled: mtls.sslEnabled,
+ disabled: mtlsReadonly,
+ onConfigure: () =>
+ openMethodEditor("mtls"),
+ onToggle: (active) =>
+ handleToggle(
+ "mtls",
+ active,
+ () =>
+ mtls.setEnabled(
+ false
+ ),
+ () =>
+ mtls.setEnabled(
+ true
+ )
+ )
+ }
+ : undefined
+ }
/>
)}
@@ -770,6 +809,22 @@ export function PolicyAuthStackSectionEdit({
setHeaderAuthActive(true);
}}
/>
+
+ {mtls.supported && (
+ {
+ if (open) return;
+ closeCredenza();
+ // mTLS can't be enabled without a trusted CA.
+ if (!mtls.hasCertificates) {
+ mtls.setEnabled(false);
+ }
+ }}
+ certificates={mtls.certificates}
+ isLoading={mtls.isLoadingCertificates}
+ />
+ )}
>
)}
diff --git a/src/components/resource-policy/policy-auth-method-id.ts b/src/components/resource-policy/policy-auth-method-id.ts
index a886735b4..9dafe1748 100644
--- a/src/components/resource-policy/policy-auth-method-id.ts
+++ b/src/components/resource-policy/policy-auth-method-id.ts
@@ -4,7 +4,8 @@ export type PolicyAuthMethodId =
| "pincode"
| "passcode"
| "email"
- | "headerAuth";
+ | "headerAuth"
+ | "mtls";
export const setPasswordSchema = z.object({
password: z.string().min(4).max(100)
diff --git a/src/components/resource-policy/policy-auth-summaries.ts b/src/components/resource-policy/policy-auth-summaries.ts
index 21898553e..643bbf378 100644
--- a/src/components/resource-policy/policy-auth-summaries.ts
+++ b/src/components/resource-policy/policy-auth-summaries.ts
@@ -43,3 +43,10 @@ export function getHeaderAuthSummary({
}: SummaryParams & { headerName: string }) {
return headerName || t("policyAuthHeaderAuthSummary");
}
+
+export function getMtlsSummary({
+ t,
+ count
+}: SummaryParams & { count: number }) {
+ return t("policyAuthMtlsSummary", { count });
+}
diff --git a/src/hooks/useResourceMtls.ts b/src/hooks/useResourceMtls.ts
new file mode 100644
index 000000000..94866b56e
--- /dev/null
+++ b/src/hooks/useResourceMtls.ts
@@ -0,0 +1,62 @@
+import ResourceContext from "@app/contexts/resourceContext";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { createApiClient } from "@app/lib/api";
+import { resourceQueries } from "@app/lib/queries";
+import type { UpdateResourceResponse } from "@server/routers/resource";
+import { useQuery } from "@tanstack/react-query";
+import type { AxiosResponse } from "axios";
+import { useContext, useEffect, useState } from "react";
+
+// mTLS is a resource-level setting, so it is only available when editing
+// authentication in the context of an HTTP resource.
+export function useResourceMtls() {
+ const resourceContext = useContext(ResourceContext);
+ const { env } = useEnvContext();
+ const api = createApiClient({ env });
+ const resource = resourceContext?.resource;
+
+ const supported =
+ !!resource &&
+ !env.flags.disableEnterpriseFeatures &&
+ resource.mode === "http";
+
+ const { data: certificates = [], isLoading: isLoadingCertificates } =
+ useQuery({
+ ...resourceQueries.resourceMtlsCertificates({
+ resourceId: resource?.resourceId ?? 0
+ }),
+ enabled: supported
+ });
+
+ const [enabled, setEnabled] = useState(Boolean(resource?.mtlsEnabled));
+
+ // The server switches mTLS off when the last CA is removed.
+ useEffect(() => {
+ setEnabled(Boolean(resource?.mtlsEnabled));
+ }, [resource?.mtlsEnabled]);
+
+ const hasCertificates = certificates.length > 0;
+ const isDirty = supported && enabled !== Boolean(resource?.mtlsEnabled);
+
+ async function save(): Promise {
+ if (!supported || !isDirty) return;
+ const res = await api.post>(
+ `/resource/${resource!.resourceId}`,
+ { mtlsEnabled: enabled }
+ );
+ resourceContext!.updateResource({ mtlsEnabled: enabled });
+ return res;
+ }
+
+ return {
+ supported,
+ sslEnabled: Boolean(resource?.ssl),
+ enabled,
+ setEnabled,
+ certificates,
+ isLoadingCertificates,
+ hasCertificates,
+ isDirty,
+ save
+ };
+}