diff --git a/cypress/integration/clients_test.spec.ts b/cypress/integration/clients_test.spec.ts
index 63ca0d6b16..2b0de307e2 100644
--- a/cypress/integration/clients_test.spec.ts
+++ b/cypress/integration/clients_test.spec.ts
@@ -294,7 +294,6 @@ describe("Clients test", () => {
cy.findByTestId("jump-link-capability-config").should("not.exist");
});
});
-
describe("SAML test", () => {
const samlClientName = "saml";
@@ -339,4 +338,60 @@ describe("Clients test", () => {
masthead.checkNotificationMessage("Client successfully updated");
});
});
+
+ describe("SAML keys tab", () => {
+ const clientId = "saml-keys";
+
+ before(() => {
+ new AdminClient().createClient({
+ clientId,
+ protocol: "saml",
+ });
+ });
+
+ after(() => {
+ new AdminClient().deleteClient(clientId);
+ });
+
+ beforeEach(() => {
+ keycloakBefore();
+ loginPage.logIn();
+ sidebarPage.goToClients();
+ listingPage.searchItem(clientId).goToItemDetails(clientId);
+ cy.get("#pf-tab-keys-keys").click();
+ });
+
+ it("doesn't disable when no", () => {
+ cy.findByTestId("clientSignature").click({ force: true });
+
+ modalUtils
+ .checkModalTitle('Disable "Client signature required"')
+ .cancelModal();
+
+ cy.findAllByTestId("certificate").should("have.length", 1);
+ });
+
+ it("disable client signature", () => {
+ cy.findByTestId("clientSignature").click({ force: true });
+
+ modalUtils
+ .checkModalTitle('Disable "Client signature required"')
+ .confirmModal();
+
+ masthead.checkNotificationMessage("Client successfully updated");
+ cy.findAllByTestId("certificate").should("have.length", 0);
+ });
+
+ it("should enable Encryption keys config", () => {
+ cy.findByTestId("encryptAssertions").click({ force: true });
+
+ cy.findByTestId("generate").click();
+ masthead.checkNotificationMessage(
+ "New key pair and certificate generated successfully"
+ );
+
+ modalUtils.confirmModal();
+ cy.findAllByTestId("certificate").should("have.length", 1);
+ });
+ });
});
diff --git a/src/clients/ClientDetails.tsx b/src/clients/ClientDetails.tsx
index 3b6cae799e..c1ca5699b6 100644
--- a/src/clients/ClientDetails.tsx
+++ b/src/clients/ClientDetails.tsx
@@ -53,6 +53,7 @@ import { ClientScopes } from "./scopes/ClientScopes";
import { EvaluateScopes } from "./scopes/EvaluateScopes";
import { ServiceAccount } from "./service-account/ServiceAccount";
import { isRealmClient, getProtocolName } from "./utils";
+import { SamlKeys } from "./keys/SamlKeys";
type ClientDetailHeaderProps = {
onChange: (value: boolean) => void;
@@ -349,7 +350,12 @@ export const ClientDetails = () => {
eventKey="keys"
title={{t("keys")}}
>
- save()} />
+ {client.protocol === "openid-connect" && (
+
+ )}
+ {client.protocol === "saml" && (
+
+ )}
)}
{!client.publicClient && !isRealmClient(client) && (
diff --git a/src/clients/add/CapabilityConfig.tsx b/src/clients/add/CapabilityConfig.tsx
index ae92d57169..db36ec06ba 100644
--- a/src/clients/add/CapabilityConfig.tsx
+++ b/src/clients/add/CapabilityConfig.tsx
@@ -248,7 +248,7 @@ export const CapabilityConfig = ({
hasNoPaddingTop
>
(
@@ -276,7 +276,7 @@ export const CapabilityConfig = ({
hasNoPaddingTop
>
(
diff --git a/src/clients/keys/Certificate.tsx b/src/clients/keys/Certificate.tsx
new file mode 100644
index 0000000000..37c59f9500
--- /dev/null
+++ b/src/clients/keys/Certificate.tsx
@@ -0,0 +1,52 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { FormGroup, GenerateId, TextArea } from "@patternfly/react-core";
+
+import type CertificateRepresentation from "@keycloak/keycloak-admin-client/lib/defs/certificateRepresentation";
+import { HelpItem } from "../../components/help-enabler/HelpItem";
+
+type CertificateProps = Omit & {
+ plain?: boolean;
+};
+
+type CertificateDisplayProps = {
+ id: string;
+ keyInfo?: CertificateRepresentation;
+};
+
+const CertificateDisplay = ({ id, keyInfo }: CertificateDisplayProps) => (
+
+);
+
+export const Certificate = ({ keyInfo, plain = false }: CertificateProps) => {
+ const { t } = useTranslation("clients");
+ return (
+
+ {(id) =>
+ plain ? (
+
+ ) : (
+
+ }
+ >
+
+
+ )
+ }
+
+ );
+};
diff --git a/src/clients/keys/GenerateKeyDialog.tsx b/src/clients/keys/GenerateKeyDialog.tsx
index dcdc01fd7c..d0349afdc9 100644
--- a/src/clients/keys/GenerateKeyDialog.tsx
+++ b/src/clients/keys/GenerateKeyDialog.tsx
@@ -1,9 +1,10 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
-import { Controller, useForm } from "react-hook-form";
+import { Control, Controller, useForm } from "react-hook-form";
import {
Button,
ButtonVariant,
+ FileUpload,
Form,
FormGroup,
Modal,
@@ -24,6 +25,99 @@ type GenerateKeyDialogProps = {
save: (keyStoreConfig: KeyStoreConfig) => void;
};
+type KeyFormProps = {
+ register: () => void;
+ control: Control;
+ useFile?: boolean;
+};
+
+export const KeyForm = ({
+ register,
+ control,
+ useFile = false,
+}: KeyFormProps) => {
+ const { t } = useTranslation("clients");
+
+ const [filename, setFilename] = useState();
+ const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
+
+ return (
+
+ );
+};
+
export const GenerateKeyDialog = ({
save,
toggleDialog,
@@ -31,8 +125,6 @@ export const GenerateKeyDialog = ({
const { t } = useTranslation("clients");
const { register, control, handleSubmit } = useForm();
- const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
-
return (
{t("clients-help:generateKeysDescription")}
-
+
);
};
diff --git a/src/clients/keys/Keys.tsx b/src/clients/keys/Keys.tsx
index 7f8edf593f..385aba4c50 100644
--- a/src/clients/keys/Keys.tsx
+++ b/src/clients/keys/Keys.tsx
@@ -13,7 +13,6 @@ import {
PageSection,
Switch,
Text,
- TextArea,
TextContent,
TextInput,
} from "@patternfly/react-core";
@@ -28,6 +27,7 @@ import { GenerateKeyDialog } from "./GenerateKeyDialog";
import { useFetch, useAdminClient } from "../../context/auth/AdminClient";
import { useAlerts } from "../../components/alert/Alerts";
import { ImportKeyDialog, ImportFile } from "./ImportKeyDialog";
+import { Certificate } from "./Certificate";
type KeysProps = {
save: () => void;
@@ -155,24 +155,7 @@ export const Keys = ({ clientId, save }: KeysProps) => {
{useJwksUrl !== "true" &&
(keyInfo ? (
-
- }
- >
-
-
+
) : (
"No client certificate configured"
))}
diff --git a/src/clients/keys/SamlImportKeyDialog.tsx b/src/clients/keys/SamlImportKeyDialog.tsx
new file mode 100644
index 0000000000..bd7abdd792
--- /dev/null
+++ b/src/clients/keys/SamlImportKeyDialog.tsx
@@ -0,0 +1,54 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { useFormContext } from "react-hook-form";
+import { AlertVariant } from "@patternfly/react-core";
+
+import type { KeyTypes } from "./SamlKeys";
+import { KeyForm } from "./GenerateKeyDialog";
+import { useAdminClient } from "../../context/auth/AdminClient";
+import { useAlerts } from "../../components/alert/Alerts";
+import { SamlKeysDialogForm, submitForm } from "./SamlKeysDialog";
+import { ConfirmDialogModal } from "../../components/confirm-dialog/ConfirmDialog";
+
+type SamlImportKeyDialogProps = {
+ id: string;
+ attr: KeyTypes;
+ onClose: () => void;
+};
+
+export const SamlImportKeyDialog = ({
+ id,
+ attr,
+ onClose,
+}: SamlImportKeyDialogProps) => {
+ const { t } = useTranslation("clients");
+ const { register, control, handleSubmit } = useFormContext();
+
+ const adminClient = useAdminClient();
+ const { addAlert, addError } = useAlerts();
+
+ const submit = (form: SamlKeysDialogForm) => {
+ submitForm(form, id, attr, adminClient, (error) => {
+ if (error) {
+ addError("clients:importError", error);
+ } else {
+ addAlert(t("importSuccess"), AlertVariant.success);
+ }
+ });
+ };
+
+ return (
+ {
+ handleSubmit(submit)();
+ onClose();
+ }}
+ >
+
+
+ );
+};
diff --git a/src/clients/keys/SamlKeys.tsx b/src/clients/keys/SamlKeys.tsx
new file mode 100644
index 0000000000..40d5a87269
--- /dev/null
+++ b/src/clients/keys/SamlKeys.tsx
@@ -0,0 +1,260 @@
+import React, { useState } from "react";
+import FileSaver from "file-saver";
+import { useTranslation } from "react-i18next";
+import { Controller, useFormContext } from "react-hook-form";
+import {
+ CardBody,
+ PageSection,
+ TextContent,
+ Text,
+ FormGroup,
+ Switch,
+ Card,
+ Form,
+ ActionGroup,
+ Button,
+ AlertVariant,
+} from "@patternfly/react-core";
+
+import type CertificateRepresentation from "@keycloak/keycloak-admin-client/lib/defs/certificateRepresentation";
+import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
+import { FormAccess } from "../../components/form-access/FormAccess";
+import { HelpItem } from "../../components/help-enabler/HelpItem";
+import type { ClientForm } from "../ClientDetails";
+import { SamlKeysDialog } from "./SamlKeysDialog";
+import { FormPanel } from "../../components/scroll-form/FormPanel";
+import { Certificate } from "./Certificate";
+import { useConfirmDialog } from "../../components/confirm-dialog/ConfirmDialog";
+import { useAlerts } from "../../components/alert/Alerts";
+import { SamlImportKeyDialog } from "./SamlImportKeyDialog";
+
+type SamlKeysProps = {
+ clientId: string;
+ save: () => void;
+};
+
+const KEYS = ["saml.signing", "saml.encryption"] as const;
+export type KeyTypes = typeof KEYS[number];
+
+const KEYS_MAPPING: { [key in KeyTypes]: { [index: string]: string } } = {
+ "saml.signing": {
+ name: "attributes.saml-client-signature",
+ title: "signingKeysConfig",
+ key: "clientSignature",
+ },
+ "saml.encryption": {
+ name: "attributes.saml-encrypt",
+ title: "encryptionKeysConfig",
+ key: "encryptAssertions",
+ },
+};
+
+type KeySectionProps = {
+ keyInfo?: CertificateRepresentation;
+ attr: KeyTypes;
+ onChanged: (key: KeyTypes) => void;
+ onGenerate: (key: KeyTypes, regenerate: boolean) => void;
+ onImport: (key: KeyTypes) => void;
+};
+
+const KeySection = ({
+ keyInfo,
+ attr,
+ onChanged,
+ onGenerate,
+ onImport,
+}: KeySectionProps) => {
+ const { t } = useTranslation("clients");
+ const { control, watch } = useFormContext();
+ const title = KEYS_MAPPING[attr].title;
+ const key = KEYS_MAPPING[attr].key;
+ const name = KEYS_MAPPING[attr].name;
+
+ const section = watch(name);
+ return (
+ <>
+
+
+ {t(`${title}Explain`)}
+
+
+
+ }
+ label={t(key)}
+ fieldId={key}
+ hasNoPaddingTop
+ >
+ (
+ {
+ const v = value.toString();
+ if (v === "true") {
+ onChanged(attr);
+ onChange(v);
+ } else {
+ onGenerate(attr, false);
+ }
+ }}
+ />
+ )}
+ />
+
+
+
+ {keyInfo?.certificate && section === "true" && (
+
+
+
+
+
+ )}
+ >
+ );
+};
+
+export const SamlKeys = ({ clientId, save }: SamlKeysProps) => {
+ const { t } = useTranslation("clients");
+ const [isChanged, setIsChanged] = useState();
+ const [keyInfo, setKeyInfo] = useState();
+ const [selectedType, setSelectedType] = useState();
+ const [openImport, setImportOpen] = useState(false);
+ const [refresh, setRefresh] = useState(0);
+
+ const { setValue } = useFormContext();
+
+ const adminClient = useAdminClient();
+ const { addAlert, addError } = useAlerts();
+
+ useFetch(
+ () =>
+ Promise.all(
+ KEYS.map((attr) =>
+ adminClient.clients.getKeyInfo({ id: clientId, attr })
+ )
+ ),
+ (info) => setKeyInfo(info),
+ [refresh]
+ );
+
+ const generate = async (attr: KeyTypes) => {
+ const index = KEYS.indexOf(attr);
+ try {
+ const info = [...(keyInfo || [])];
+ info[index] = await adminClient.clients.generateKey({
+ id: clientId,
+ attr,
+ });
+
+ setKeyInfo(info);
+ FileSaver.saveAs(
+ new Blob([info[index].privateKey!], {
+ type: "application/octet-stream",
+ }),
+ "private.key"
+ );
+
+ addAlert(t("generateSuccess"), AlertVariant.success);
+ } catch (error) {
+ addError("clients:generateError", error);
+ }
+ };
+
+ const key = selectedType ? KEYS_MAPPING[selectedType].key : "";
+ const [toggleDisableDialog, DisableConfirm] = useConfirmDialog({
+ titleKey: t("disableSigning", {
+ key: t(key),
+ }),
+ messageKey: t("disableSigningExplain", {
+ key: t(key),
+ }),
+ continueButtonLabel: "common:yes",
+ cancelButtonLabel: "common:no",
+ onConfirm: () => {
+ setValue(KEYS_MAPPING[selectedType!].name, "false");
+ save();
+ },
+ });
+
+ const [toggleReGenerateDialog, ReGenerateConfirm] = useConfirmDialog({
+ titleKey: "clients:reGenerateSigning",
+ messageKey: "clients:reGenerateSigningExplain",
+ continueButtonLabel: "common:yes",
+ cancelButtonLabel: "common:no",
+ onConfirm: () => {
+ generate(selectedType!);
+ },
+ });
+
+ return (
+
+ {isChanged && (
+ {
+ setIsChanged(undefined);
+ save();
+ setRefresh(refresh + 1);
+ }}
+ />
+ )}
+
+
+ {KEYS.map((attr, index) => (
+ <>
+ {openImport && (
+ setImportOpen(false)}
+ />
+ )}
+ {
+ setSelectedType(type);
+ if (!isNew) {
+ toggleDisableDialog();
+ } else {
+ toggleReGenerateDialog();
+ }
+ }}
+ onImport={() => setImportOpen(true)}
+ />
+ >
+ ))}
+
+ );
+};
diff --git a/src/clients/keys/SamlKeysDialog.tsx b/src/clients/keys/SamlKeysDialog.tsx
new file mode 100644
index 0000000000..56a79e63f0
--- /dev/null
+++ b/src/clients/keys/SamlKeysDialog.tsx
@@ -0,0 +1,206 @@
+import React, { useState } from "react";
+import FileSaver from "file-saver";
+import { useTranslation } from "react-i18next";
+import { useForm } from "react-hook-form";
+import {
+ AlertVariant,
+ Button,
+ ButtonVariant,
+ Flex,
+ FlexItem,
+ Form,
+ FormGroup,
+ Modal,
+ ModalVariant,
+ Radio,
+ Split,
+ SplitItem,
+ Text,
+ TextContent,
+ Title,
+} from "@patternfly/react-core";
+
+import type CertificateRepresentation from "@keycloak/keycloak-admin-client/lib/defs/certificateRepresentation";
+import type KeyStoreConfig from "@keycloak/keycloak-admin-client/lib/defs/keystoreConfig";
+import type { KeyTypes } from "./SamlKeys";
+import { HelpItem } from "../../components/help-enabler/HelpItem";
+import { useAdminClient } from "../../context/auth/AdminClient";
+import { useAlerts } from "../../components/alert/Alerts";
+import { KeyForm } from "./GenerateKeyDialog";
+import { Certificate } from "./Certificate";
+import type KeycloakAdminClient from "@keycloak/keycloak-admin-client";
+
+type SamlKeysDialogProps = {
+ id: string;
+ attr: KeyTypes;
+ onClose: () => void;
+};
+
+export type SamlKeysDialogForm = KeyStoreConfig & {
+ file: File;
+};
+
+export const submitForm = async (
+ form: SamlKeysDialogForm,
+ id: string,
+ attr: KeyTypes,
+ adminClient: KeycloakAdminClient,
+ callback: (error?: unknown) => void
+) => {
+ try {
+ const formData = new FormData();
+ const { file, ...rest } = form;
+ Object.entries(rest).map(([key, value]) =>
+ formData.append(
+ key === "format" ? "keystoreFormat" : key,
+ value.toString()
+ )
+ );
+ formData.append("file", file);
+
+ await adminClient.clients.uploadKey({ id, attr }, formData);
+ callback();
+ } catch (error) {
+ callback(error);
+ }
+};
+
+export const SamlKeysDialog = ({ id, attr, onClose }: SamlKeysDialogProps) => {
+ const { t } = useTranslation("clients");
+ const [type, setType] = useState(false);
+ const [keys, setKeys] = useState();
+ const { register, control, handleSubmit } = useForm();
+
+ const adminClient = useAdminClient();
+ const { addAlert, addError } = useAlerts();
+
+ const submit = (form: SamlKeysDialogForm) => {
+ submitForm(form, id, attr, adminClient, (error) => {
+ if (error) {
+ addError("clients:importError", error);
+ } else {
+ addAlert(t("importSuccess"), AlertVariant.success);
+ }
+ });
+ };
+
+ const generate = async () => {
+ try {
+ const key = await adminClient.clients.generateKey({
+ id,
+ attr,
+ });
+ setKeys(key);
+ FileSaver.saveAs(
+ new Blob([key.privateKey!], {
+ type: "application/octet-stream",
+ }),
+ "private.key"
+ );
+
+ addAlert(t("generateSuccess"), AlertVariant.success);
+ } catch (error) {
+ addError("clients:generateError", error);
+ }
+ };
+
+ return (
+
+ {t("enableClientSignatureRequired")}
+ {t("enableClientSignatureRequiredExplain")}
+
+ }
+ isOpen={true}
+ onClose={onClose}
+ actions={[
+ ,
+ ,
+ ]}
+ >
+
+ {!type && (
+
+ )}
+ {type && }
+
+ );
+};
diff --git a/src/clients/messages.ts b/src/clients/messages.ts
index 56306d4199..7b43e82464 100644
--- a/src/clients/messages.ts
+++ b/src/clients/messages.ts
@@ -169,6 +169,29 @@ export default {
"If you regenerate registration access token, the access data regarding the client registration service will be updated.",
clientSecretSuccess: "Client secret regenerated",
clientSecretError: "Could not regenerate client secret due to: {{error}}",
+ signingKeysConfig: "Signing keys config",
+ signingKeysConfigExplain:
+ 'If you enable the "Client signature required" below, you must configure the signing keys by generating or importing keys, and the client will sign their saml requests and responses. The signature will be validated.',
+ encryptionKeysConfig: "Encryption keys config",
+ encryptionKeysConfigExplain:
+ 'If you enable the "Encryption assertions" below, you must configure the encryption keys by generating or importing keys, and the SAML assertions will be encrypted with the client\'s public key using AES.',
+ enableClientSignatureRequired: 'Enable "Client signature required"?',
+ enableClientSignatureRequiredExplain:
+ 'If you enable "Client signature required", the adapter of this client will be updated. You may need to download a new adapter for this client. You need to generate or import keys for this client otherwise the authentication will not work.',
+ selectMethod: "Select method",
+ selectMethodType: {
+ generate: "Generate",
+ import: "Import",
+ },
+ confirm: "Confirm",
+ browse: "Browse",
+ importKey: "Import key",
+ disableSigning: 'Disable "{{key}}"',
+ disableSigningExplain:
+ 'If you disable "{{key}}", the Keycloak database will be updated and you may need to download a new adapter for this client.',
+ reGenerateSigning: "Regenerate signing key for this client",
+ reGenerateSigningExplain:
+ "If you regenerate signing key for client, the Keycloak database will be updated and you may need to download a new adapter for this client.",
registrationAccessToken: "Registration access token",
accessTokenSuccess: "Access token regenerated",
accessTokenError: "Could not regenerate access token due to: {{error}}",