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 + }; +}