added saml keys tab (#1308)

Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Erik Jan de Wit 2021-10-12 11:28:55 +02:00 committed by GitHub
parent 6f09d581a5
commit 60e4676ac8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 758 additions and 68 deletions

View file

@ -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);
});
});
});

View file

@ -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={<TabTitleText>{t("keys")}</TabTitleText>}
>
<Keys clientId={clientId} save={() => save()} />
{client.protocol === "openid-connect" && (
<Keys clientId={clientId} save={save} />
)}
{client.protocol === "saml" && (
<SamlKeys clientId={clientId} save={save} />
)}
</Tab>
)}
{!client.publicClient && !isRealmClient(client) && (

View file

@ -248,7 +248,7 @@ export const CapabilityConfig = ({
hasNoPaddingTop
>
<Controller
name="attributes.saml_encrypt"
name="attributes.saml-encrypt"
control={control}
defaultValue="false"
render={({ onChange, value }) => (
@ -276,7 +276,7 @@ export const CapabilityConfig = ({
hasNoPaddingTop
>
<Controller
name="attributes.saml_client_signature"
name="attributes.saml-client-signature"
control={control}
defaultValue="false"
render={({ onChange, value }) => (

View file

@ -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<CertificateDisplayProps, "id"> & {
plain?: boolean;
};
type CertificateDisplayProps = {
id: string;
keyInfo?: CertificateRepresentation;
};
const CertificateDisplay = ({ id, keyInfo }: CertificateDisplayProps) => (
<TextArea
readOnly
rows={5}
id={id}
data-testid="certificate"
value={keyInfo?.certificate}
/>
);
export const Certificate = ({ keyInfo, plain = false }: CertificateProps) => {
const { t } = useTranslation("clients");
return (
<GenerateId prefix="certificate">
{(id) =>
plain ? (
<CertificateDisplay id={id} keyInfo={keyInfo} />
) : (
<FormGroup
label={t("certificate")}
fieldId={id}
labelIcon={
<HelpItem
helpText="clients-help:certificate"
forLabel={t("certificate")}
forID={id}
/>
}
>
<CertificateDisplay id={id} keyInfo={keyInfo} />
</FormGroup>
)
}
</GenerateId>
);
};

View file

@ -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<KeyStoreConfig>;
useFile?: boolean;
};
export const KeyForm = ({
register,
control,
useFile = false,
}: KeyFormProps) => {
const { t } = useTranslation("clients");
const [filename, setFilename] = useState<string>();
const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
return (
<Form className="pf-u-pt-lg">
<FormGroup
label={t("archiveFormat")}
labelIcon={
<HelpItem
helpText="clients-help:archiveFormat"
forLabel={t("archiveFormat")}
forID="archiveFormat"
/>
}
fieldId="archiveFormat"
>
<Controller
name="format"
defaultValue="JKS"
control={control}
render={({ onChange, value }) => (
<Select
toggleId="archiveFormat"
onToggle={(isExpanded) => setOpenArchiveFormat(isExpanded)}
onSelect={(_, value) => {
onChange(value.toString());
setOpenArchiveFormat(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("archiveFormat")}
isOpen={openArchiveFormat}
>
{["JKS", "PKCS12"].map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
{useFile && (
<FormGroup
label={t("importFile")}
labelIcon={
<HelpItem
helpText="clients-help:importFile"
forLabel={t("importFile")}
forID="importFile"
/>
}
fieldId="importFile"
>
<Controller
name="file"
defaultValue=""
control={control}
render={({ onChange, value }) => (
<FileUpload
id="importFile"
value={value}
filename={filename}
browseButtonText={t("browse")}
onChange={(value, filename) => {
setFilename(filename);
onChange(value);
}}
/>
)}
/>
</FormGroup>
)}
<StoreSettings register={register} hidePassword={useFile} />
</Form>
);
};
export const GenerateKeyDialog = ({
save,
toggleDialog,
@ -31,8 +125,6 @@ export const GenerateKeyDialog = ({
const { t } = useTranslation("clients");
const { register, control, handleSubmit } = useForm<KeyStoreConfig>();
const [openArchiveFormat, setOpenArchiveFormat] = useState(false);
return (
<Modal
variant={ModalVariant.medium}
@ -69,48 +161,7 @@ export const GenerateKeyDialog = ({
<TextContent>
<Text>{t("clients-help:generateKeysDescription")}</Text>
</TextContent>
<Form className="pf-u-pt-lg">
<FormGroup
label={t("archiveFormat")}
labelIcon={
<HelpItem
helpText="clients-help:archiveFormat"
forLabel={t("archiveFormat")}
forID="archiveFormat"
/>
}
fieldId="archiveFormat"
>
<Controller
name="format"
defaultValue="JKS"
control={control}
render={({ onChange, value }) => (
<Select
toggleId="archiveFormat"
onToggle={() => setOpenArchiveFormat(!openArchiveFormat)}
onSelect={(_, value) => {
onChange(value as string);
setOpenArchiveFormat(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("archiveFormat")}
isOpen={openArchiveFormat}
>
{["JKS", "PKCS12"].map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
/>
))}
</Select>
)}
/>
</FormGroup>
<StoreSettings register={register} />
</Form>
<KeyForm register={register} control={control} />
</Modal>
);
};

View file

@ -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) => {
</FormGroup>
{useJwksUrl !== "true" &&
(keyInfo ? (
<FormGroup
label={t("certificate")}
fieldId="certificate"
labelIcon={
<HelpItem
helpText="clients-help:certificate"
forLabel={t("certificate")}
forID="certificate"
/>
}
>
<TextArea
readOnly
rows={5}
id="certificate"
value={keyInfo.certificate}
/>
</FormGroup>
<Certificate plain keyInfo={keyInfo} />
) : (
"No client certificate configured"
))}

View file

@ -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 (
<ConfirmDialogModal
open={true}
toggleDialog={onClose}
continueButtonLabel="clients:import"
titleKey="clients:importKey"
onConfirm={() => {
handleSubmit(submit)();
onClose();
}}
>
<KeyForm register={register} control={control} useFile />
</ConfirmDialogModal>
);
};

View file

@ -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<ClientForm>();
const title = KEYS_MAPPING[attr].title;
const key = KEYS_MAPPING[attr].key;
const name = KEYS_MAPPING[attr].name;
const section = watch(name);
return (
<>
<FormPanel title={t(title)} className="kc-form-panel__panel">
<TextContent className="pf-u-pb-lg">
<Text>{t(`${title}Explain`)}</Text>
</TextContent>
<FormAccess role="manage-clients" isHorizontal>
<FormGroup
labelIcon={
<HelpItem
helpText={`clients-help:${key}`}
forLabel={t(key)}
forID={t(`common:helpLabel`, { label: t(key) })}
/>
}
label={t(key)}
fieldId={key}
hasNoPaddingTop
>
<Controller
name={name}
control={control}
defaultValue="false"
render={({ onChange, value }) => (
<Switch
data-testid={key}
id={key}
label={t("common:on")}
labelOff={t("common:off")}
isChecked={value === "true"}
onChange={(value) => {
const v = value.toString();
if (v === "true") {
onChanged(attr);
onChange(v);
} else {
onGenerate(attr, false);
}
}}
/>
)}
/>
</FormGroup>
</FormAccess>
</FormPanel>
{keyInfo?.certificate && section === "true" && (
<Card isFlat>
<CardBody className="kc-form-panel__body">
<Form isHorizontal>
<Certificate keyInfo={keyInfo} />
<ActionGroup>
<Button
variant="secondary"
onClick={() => onGenerate(attr, true)}
>
{t("regenerate")}
</Button>
<Button variant="secondary" onClick={() => onImport(attr)}>
{t("importKey")}
</Button>
<Button variant="tertiary">{t("common:export")}</Button>
</ActionGroup>
</Form>
</CardBody>
</Card>
)}
</>
);
};
export const SamlKeys = ({ clientId, save }: SamlKeysProps) => {
const { t } = useTranslation("clients");
const [isChanged, setIsChanged] = useState<KeyTypes>();
const [keyInfo, setKeyInfo] = useState<CertificateRepresentation[]>();
const [selectedType, setSelectedType] = useState<KeyTypes>();
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 (
<PageSection variant="light" className="keycloak__form">
{isChanged && (
<SamlKeysDialog
id={clientId}
attr={isChanged}
onClose={() => {
setIsChanged(undefined);
save();
setRefresh(refresh + 1);
}}
/>
)}
<DisableConfirm />
<ReGenerateConfirm />
{KEYS.map((attr, index) => (
<>
{openImport && (
<SamlImportKeyDialog
id={clientId}
attr={attr}
onClose={() => setImportOpen(false)}
/>
)}
<KeySection
key={attr}
keyInfo={keyInfo?.[index]}
attr={attr}
onChanged={setIsChanged}
onGenerate={(type, isNew) => {
setSelectedType(type);
if (!isNew) {
toggleDisableDialog();
} else {
toggleReGenerateDialog();
}
}}
onImport={() => setImportOpen(true)}
/>
</>
))}
</PageSection>
);
};

View file

@ -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<CertificateRepresentation>();
const { register, control, handleSubmit } = useForm<SamlKeysDialogForm>();
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 (
<Modal
variant={ModalVariant.medium}
aria-labelledby={t("enableClientSignatureRequired")}
header={
<TextContent>
<Title headingLevel="h1">{t("enableClientSignatureRequired")}</Title>
<Text>{t("enableClientSignatureRequiredExplain")}</Text>
</TextContent>
}
isOpen={true}
onClose={onClose}
actions={[
<Button
id="modal-confirm"
key="confirm"
data-testid="confirm"
variant="primary"
onClick={() => {
if (type) {
handleSubmit(submit)();
}
onClose();
}}
>
{t("confirm")}
</Button>,
<Button
id="modal-cancel"
key="cancel"
data-testid="cancel"
variant={ButtonVariant.link}
onClick={onClose}
>
{t("common:cancel")}
</Button>,
]}
>
<Form isHorizontal>
<FormGroup
label={t("selectMethod")}
fieldId="selectMethod"
hasNoPaddingTop
>
<Flex>
<FlexItem>
<Radio
isChecked={!type}
name="selectMethodType"
onChange={() => setType(false)}
label={t("selectMethodType.generate")}
id="selectMethodType-generate"
/>
</FlexItem>
<FlexItem>
<Radio
isChecked={type}
name="selectMethodType"
onChange={() => setType(true)}
label={t("selectMethodType.import")}
id="selectMethodType-import"
/>
</FlexItem>
</Flex>
</FormGroup>
</Form>
{!type && (
<Form>
<FormGroup
label={t("certificate")}
fieldId="certificate"
labelIcon={
<HelpItem
helpText="clients-help:certificate"
forLabel={t("certificate")}
forID="certificate"
/>
}
>
<Split hasGutter>
<SplitItem isFilled>
<Certificate plain keyInfo={keys} />
</SplitItem>
<SplitItem>
<Button
variant="secondary"
data-testid="generate"
onClick={generate}
>
{t("generate")}
</Button>
</SplitItem>
</Split>
</FormGroup>
</Form>
)}
{type && <KeyForm register={register} control={control} useFile />}
</Modal>
);
};

View file

@ -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}}",