Add UI and partial functionality for creating SAML identity providers (#944)

* fix OIDC string issue

* preliminary UI changes

* saml create new fields added

* use new routing for saml

* add msg strings

* most fields added

* add reqAuthnConstraints section

* final help strings and options

* add cypress tests

* fix legacy cypress cleanup tasks

* fix selects and hardcoded strings

* most PR review comments incorporated

* PR review edits

* more PR review comments
This commit is contained in:
mfrances17 2021-08-19 10:41:27 -04:00 committed by GitHub
parent c5abf8349e
commit 7efa03dc3f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1042 additions and 42 deletions

View file

@ -14,6 +14,16 @@ describe("Identity provider test", () => {
const masthead = new Masthead(); const masthead = new Masthead();
const listingPage = new ListingPage(); const listingPage = new ListingPage();
const createProviderPage = new CreateProviderPage(); const createProviderPage = new CreateProviderPage();
const createSuccessMsg = "Identity provider successfully created";
const changeSuccessMsg =
"Successfully changed display order of identity providers";
const deletePrompt = "Delete provider?";
const deleteSuccessMsg = "Provider successfully deleted";
const keycloakServer = Cypress.env("KEYCLOAK_SERVER");
const discoveryUrl = `${keycloakServer}/auth/realms/master/.well-known/openid-configuration`;
const authorizationUrl = `${keycloakServer}/auth/realms/master/protocol/openid-connect/auth`;
const ssoServiceUrl = `${keycloakServer}/auth/realms/sso`;
describe("Identity provider creation", () => { describe("Identity provider creation", () => {
const identityProviderName = "github"; const identityProviderName = "github";
@ -33,9 +43,7 @@ describe("Identity provider test", () => {
.clickAdd() .clickAdd()
.checkClientIdRequiredMessage(true); .checkClientIdRequiredMessage(true);
createProviderPage.fill(identityProviderName, "123").clickAdd(); createProviderPage.fill(identityProviderName, "123").clickAdd();
masthead.checkNotificationMessage( masthead.checkNotificationMessage(createSuccessMsg);
"Identity provider successfully created"
);
sidebarPage.goToIdentityProviders(); sidebarPage.goToIdentityProviders();
listingPage.itemExist(identityProviderName); listingPage.itemExist(identityProviderName);
@ -44,9 +52,9 @@ describe("Identity provider test", () => {
it("should delete provider", () => { it("should delete provider", () => {
const modalUtils = new ModalUtils(); const modalUtils = new ModalUtils();
listingPage.deleteItem(identityProviderName); listingPage.deleteItem(identityProviderName);
modalUtils.checkModalTitle("Delete provider?").confirmModal(); modalUtils.checkModalTitle(deletePrompt).confirmModal();
masthead.checkNotificationMessage("Provider successfully deleted"); masthead.checkNotificationMessage(deleteSuccessMsg);
createProviderPage.checkGitHubCardVisible(); createProviderPage.checkGitHubCardVisible();
}); });
@ -90,50 +98,60 @@ describe("Identity provider test", () => {
orderDialog.checkOrder(["facebook", "bitbucket", identityProviderName]); orderDialog.checkOrder(["facebook", "bitbucket", identityProviderName]);
orderDialog.clickSave(); orderDialog.clickSave();
masthead.checkNotificationMessage( masthead.checkNotificationMessage(changeSuccessMsg);
"Successfully changed display order of identity providers"
);
}); });
it("should create a oidc provider using discovery url", () => { it("should create a oidc provider using discovery url", () => {
const oidcProviderName = "oidc"; const oidcProviderName = "oidc";
const keycloakServer = Cypress.env("KEYCLOAK_SERVER");
createProviderPage createProviderPage
.clickCreateDropdown() .clickCreateDropdown()
.clickItem(oidcProviderName) .clickItem(oidcProviderName)
.fillDiscoveryUrl( .fillDiscoveryUrl(discoveryUrl)
`${keycloakServer}/auth/realms/master/.well-known/openid-configuration`
)
.shouldBeSuccessful() .shouldBeSuccessful()
.fill("oidc", "123") .fill("oidc", "123")
.clickAdd(); .clickAdd();
masthead.checkNotificationMessage( masthead.checkNotificationMessage(createSuccessMsg);
"Identity provider successfully created" createProviderPage.shouldHaveAuthorizationUrl(authorizationUrl);
);
createProviderPage.shouldHaveAuthorizationUrl(
`${keycloakServer}/auth/realms/master/protocol/openid-connect/auth`
);
}); });
// it("clean up providers", () => { it("should create a SAML provider using SSO service url", () => {
// const modalUtils = new ModalUtils(); const samlProviderName = "saml";
// listingPage.deleteItem("bitbucket"); createProviderPage
// modalUtils.checkModalTitle("Delete provider?").confirmModal(); .clickCreateDropdown()
// masthead.checkNotificationMessage("Provider successfully deleted"); .clickItem(samlProviderName)
.toggleEntityDescriptor()
.fillSsoServiceUrl(ssoServiceUrl)
.clickAdd();
masthead.checkNotificationMessage(createSuccessMsg);
});
// listingPage.deleteItem("facebook"); it("clean up providers", () => {
// modalUtils.checkModalTitle("Delete provider?").confirmModal(); const modalUtils = new ModalUtils();
// masthead.checkNotificationMessage("Provider successfully deleted");
// listingPage.deleteItem("github"); sidebarPage.goToIdentityProviders();
// modalUtils.checkModalTitle("Delete provider?").confirmModal(); listingPage.itemExist("bitbucket").deleteItem("bitbucket");
// masthead.checkNotificationMessage("Provider successfully deleted"); modalUtils.checkModalTitle(deletePrompt).confirmModal();
masthead.checkNotificationMessage(deleteSuccessMsg);
// listingPage.deleteItem("oidc"); sidebarPage.goToIdentityProviders();
// modalUtils.checkModalTitle("Delete provider?").confirmModal(); listingPage.itemExist("facebook").deleteItem("facebook");
// masthead.checkNotificationMessage("Provider successfully deleted"); modalUtils.checkModalTitle(deletePrompt).confirmModal();
// }); masthead.checkNotificationMessage(deleteSuccessMsg);
sidebarPage.goToIdentityProviders();
listingPage.itemExist("github").deleteItem("github");
modalUtils.checkModalTitle(deletePrompt).confirmModal();
masthead.checkNotificationMessage(deleteSuccessMsg);
sidebarPage.goToIdentityProviders();
listingPage.itemExist("oidc").deleteItem("oidc");
modalUtils.checkModalTitle(deletePrompt).confirmModal();
masthead.checkNotificationMessage(deleteSuccessMsg);
sidebarPage.goToIdentityProviders();
listingPage.itemExist("saml").deleteItem("saml");
modalUtils.checkModalTitle(deletePrompt).confirmModal();
masthead.checkNotificationMessage(deleteSuccessMsg);
});
}); });
}); });

View file

@ -6,7 +6,9 @@ export default class CreateProviderPage {
private clientSecretField = "clientSecret"; private clientSecretField = "clientSecret";
private discoveryEndpoint = "discoveryEndpoint"; private discoveryEndpoint = "discoveryEndpoint";
private authorizationUrl = "authorizationUrl"; private authorizationUrl = "authorizationUrl";
private useEntityDescriptorSwitch = "useEntityDescriptor";
private addButton = "createProvider"; private addButton = "createProvider";
private ssoServiceUrl = "sso-service-url";
checkVisible(name: string) { checkVisible(name: string) {
cy.getId(`${name}-card`).should("exist"); cy.getId(`${name}-card`).should("exist");
@ -74,6 +76,12 @@ export default class CreateProviderPage {
return this; return this;
} }
fillSsoServiceUrl(value: string) {
cy.getId(this.ssoServiceUrl).type("x");
cy.getId(this.ssoServiceUrl).clear().type(value).blur();
return this;
}
shouldBeSuccessful() { shouldBeSuccessful() {
cy.getId(this.discoveryEndpoint).should("have.class", "pf-m-success"); cy.getId(this.discoveryEndpoint).should("have.class", "pf-m-success");
return this; return this;
@ -83,4 +91,9 @@ export default class CreateProviderPage {
cy.getId(this.authorizationUrl).should("have.value", value); cy.getId(this.authorizationUrl).should("have.value", value);
return this; return this;
} }
toggleEntityDescriptor() {
cy.getId(this.useEntityDescriptorSwitch).click({ force: true });
return this;
}
} }

View file

@ -0,0 +1,90 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FormProvider, useForm } from "react-hook-form";
import {
ActionGroup,
AlertVariant,
Button,
PageSection,
} from "@patternfly/react-core";
import type IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
import { FormAccess } from "../../components/form-access/FormAccess";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useAdminClient } from "../../context/auth/AdminClient";
import { SamlGeneralSettings } from "./SamlGeneralSettings";
import { SamlConnectSettings } from "./SamlConnectSettings";
import { useRealm } from "../../context/realm-context/RealmContext";
import { useAlerts } from "../../components/alert/Alerts";
export const AddSamlConnect = () => {
const { t } = useTranslation("identity-providers");
const history = useHistory();
const id = "saml";
const form = useForm<IdentityProviderRepresentation>({
defaultValues: { alias: id },
});
const {
handleSubmit,
formState: { isDirty },
} = form;
const adminClient = useAdminClient();
const { addAlert } = useAlerts();
const { realm } = useRealm();
const save = async (provider: IdentityProviderRepresentation) => {
try {
await adminClient.identityProviders.create({
...provider,
providerId: id,
});
addAlert(t("createSuccess"), AlertVariant.success);
history.push(`/${realm}/identity-providers/${id}/settings`);
} catch (error) {
addAlert(
t("createError", {
error: error.response?.data?.errorMessage || error,
}),
AlertVariant.danger
);
}
};
return (
<>
<ViewHeader titleKey={t("addSamlProvider")} />
<PageSection variant="light">
<FormProvider {...form}>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<SamlGeneralSettings id={id} />
<SamlConnectSettings />
<ActionGroup>
<Button
isDisabled={!isDirty}
variant="primary"
type="submit"
data-testid="createProvider"
>
{t("common:add")}
</Button>
<Button
variant="link"
data-testid="cancel"
onClick={() => history.push(`/${realm}/identity-providers`)}
>
{t("common:cancel")}
</Button>
</ActionGroup>
</FormAccess>
</FormProvider>
</PageSection>
</>
);
};

View file

@ -90,16 +90,25 @@ const LoginFlow = ({
}; };
const syncModes = ["import", "legacy", "force"]; const syncModes = ["import", "legacy", "force"];
type AdvancedSettingsProps = { isOIDC: boolean; isSAML: boolean };
export const AdvancedSettings = ({ isOIDC }: { isOIDC: boolean }) => { export const AdvancedSettings = ({ isOIDC, isSAML }: AdvancedSettingsProps) => {
const { t } = useTranslation("identity-providers"); const { t } = useTranslation("identity-providers");
const { control } = useFormContext(); const { control } = useFormContext();
const [syncModeOpen, setSyncModeOpen] = useState(false); const [syncModeOpen, setSyncModeOpen] = useState(false);
return ( return (
<> <>
{!isOIDC && <TextField field="config.defaultScope" label="scopes" />} {!isOIDC && !isSAML && (
<TextField field="config.defaultScope" label="scopes" />
)}
<SwitchField field="storeToken" label="storeTokens" fieldType="boolean" /> <SwitchField field="storeToken" label="storeTokens" fieldType="boolean" />
{!isOIDC && ( {isSAML && (
<SwitchField
field="config.addReadTokenRoleOnCreate"
label="storedTokensReadable"
/>
)}
{!isOIDC && !isSAML && (
<> <>
<SwitchField <SwitchField
field="config.acceptsPromptNoneForwardFromClient" field="config.acceptsPromptNoneForwardFromClient"

View file

@ -0,0 +1,401 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import {
ExpandableSection,
FormGroup,
Select,
SelectOption,
SelectVariant,
TextInput,
ValidatedOptions,
} from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { SwitchField } from "../component/SwitchField";
import { TextField } from "../component/TextField";
import "./discovery-settings.css";
type DescriptorSettingsProps = {
readOnly: boolean;
};
const Fields = ({ readOnly }: DescriptorSettingsProps) => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { register, control, errors } = useFormContext();
const [namedPolicyDropdownOpen, setNamedPolicyDropdownOpen] = useState(false);
const [principalTypeDropdownOpen, setPrincipalTypeDropdownOpen] =
useState(false);
const [signatureAlgorithmDropdownOpen, setSignatureAlgorithmDropdownOpen] =
useState(false);
const [
samlSignatureKeyNameDropdownOpen,
setSamlSignatureKeyNameDropdownOpen,
] = useState(false);
const wantAuthnSigned = useWatch({
control,
name: "config.wantAuthnRequestsSigned",
});
const validateSignature = useWatch({
control,
name: "config.validateSignature",
});
return (
<div className="pf-c-form pf-m-horizontal">
<FormGroup
label={t("ssoServiceUrl")}
labelIcon={
<HelpItem
helpText={th("ssoServiceUrl")}
forLabel={t("ssoServiceUrl")}
forID="kc-sso-service-url"
/>
}
fieldId="kc-sso-service-url"
isRequired
validated={
errors.config?.authorizationUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
type="text"
data-testid="sso-service-url"
id="kc-sso-service-url"
name="config.singleSignOnServiceUrl"
ref={register({ required: true })}
validated={
errors.config?.singleSignOnServiceUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
isReadOnly={readOnly}
/>
</FormGroup>
<FormGroup
label={t("singleLogoutServiceUrl")}
labelIcon={
<HelpItem
helpText={th("singleLogoutServiceUrl")}
forLabel={t("singleLogoutServiceUrl")}
forID="single-logout-service-url"
/>
}
fieldId="single-logout-service-url"
validated={
errors.config?.singleLogoutServiceUrl
? ValidatedOptions.error
: ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
type="text"
id="single-logout-service-url"
name="config.singleLogoutServiceUrl"
isReadOnly={readOnly}
/>
</FormGroup>
<SwitchField
field="config.backchannelSupported"
label="backchannelLogout"
isReadOnly={readOnly}
/>
<FormGroup
label={t("nameIdPolicyFormat")}
labelIcon={
<HelpItem
helpText={th("nameIdPolicyFormat")}
forLabel={t("nameIdPolicyFormat")}
forID="kc-nameIdPolicyFormat"
/>
}
fieldId="kc-nameIdPolicyFormat"
helperTextInvalid={t("common:required")}
>
<Controller
name="config.nameIDPolicyFormat"
defaultValue={t("persistent")}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-nameIdPolicyFormat"
onToggle={(isExpanded) => setNamedPolicyDropdownOpen(isExpanded)}
isOpen={namedPolicyDropdownOpen}
onSelect={(_, value) => {
onChange(value as string);
setNamedPolicyDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption
data-testid="persistent-option"
value={t("persistent")}
isPlaceholder
/>
<SelectOption
data-testid="transient-option"
value={t("transient")}
/>
<SelectOption data-testid="email-option" value={t("email")} />
<SelectOption
data-testid="kerberos-option"
value={t("kerberos")}
/>
<SelectOption data-testid="x509-option" value={t("x509")} />
<SelectOption
data-testid="windowsDomainQN-option"
value={t("windowsDomainQN")}
/>
<SelectOption
data-testid="unspecified-option"
value={t("unspecified")}
/>
</Select>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("principalType")}
labelIcon={
<HelpItem
helpText={th("principalType")}
forLabel={t("principalType")}
forID="kc-principalType"
/>
}
fieldId="kc-principalType"
helperTextInvalid={t("common:required")}
>
<Controller
name="config.principalType"
defaultValue={t("subjectNameId")}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-principalType"
onToggle={(isExpanded) =>
setPrincipalTypeDropdownOpen(isExpanded)
}
isOpen={principalTypeDropdownOpen}
onSelect={(_, value) => {
onChange(value.toString());
setPrincipalTypeDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption
data-testid="subjectNameId-option"
value={t("subjectNameId")}
isPlaceholder
/>
<SelectOption
data-testid="attributeName-option"
value={t("attributeName")}
/>
<SelectOption
data-testid="attributeFriendlyName-option"
value={t("attributeFriendlyName")}
/>
</Select>
)}
></Controller>
</FormGroup>
<SwitchField
field="config.postBindingResponse"
label="httpPostBindingResponse"
isReadOnly={readOnly}
/>
<SwitchField
field="config.postBindingAuthnRequest"
label="httpPostBindingAuthnRequest"
isReadOnly={readOnly}
/>
<SwitchField
field="config.postBindingLogout"
label="httpPostBindingLogout"
isReadOnly={readOnly}
/>
<SwitchField
field="config.wantAuthnRequestsSigned"
label="wantAuthnRequestsSigned"
isReadOnly={readOnly}
/>
{wantAuthnSigned === "true" && (
<>
<FormGroup
label={t("signatureAlgorithm")}
labelIcon={
<HelpItem
helpText={th("signatureAlgorithm")}
forLabel={t("signatureAlgorithm")}
forID="kc-signatureAlgorithm"
/>
}
fieldId="kc-signatureAlgorithm"
>
<Controller
name="config.signatureAlgorithm"
defaultValue="RSA_SHA256"
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-signatureAlgorithm"
onToggle={(isExpanded) =>
setSignatureAlgorithmDropdownOpen(isExpanded)
}
isOpen={signatureAlgorithmDropdownOpen}
onSelect={(_, value) => {
onChange(value.toString());
setSignatureAlgorithmDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption value="RSA_SHA1" />
<SelectOption value="RSA_SHA256" isPlaceholder />
<SelectOption value="RSA_SHA256_MGF1" />
<SelectOption value="RSA_SHA512" />
<SelectOption value="RSA_SHA512_MGF1" />
<SelectOption value="DSA_SHA1" />
</Select>
)}
></Controller>
</FormGroup>
<FormGroup
label={t("samlSignatureKeyName")}
labelIcon={
<HelpItem
helpText={th("samlSignatureKeyName")}
forLabel={t("samlSignatureKeyName")}
forID="kc-samlSignatureKeyName"
/>
}
fieldId="kc-samlSignatureKeyName"
>
<Controller
name="config.xmlSigKeyInfoKeyNameTransformer"
defaultValue="keyID-option"
control={control}
render={({ onChange, value }) => (
<Select
toggleId="kc-samlSignatureKeyName"
onToggle={(isExpanded) =>
setSamlSignatureKeyNameDropdownOpen(isExpanded)
}
isOpen={samlSignatureKeyNameDropdownOpen}
onSelect={(_, value) => {
onChange(value.toString());
setSamlSignatureKeyNameDropdownOpen(false);
}}
selections={value}
variant={SelectVariant.single}
>
<SelectOption value="NONE" />
<SelectOption value={t("keyID")} isPlaceholder />
<SelectOption value={t("certSubject")} />
</Select>
)}
></Controller>
</FormGroup>
</>
)}
<SwitchField
field="config.wantAssertionsSigned"
label="wantAssertionsSigned"
isReadOnly={readOnly}
/>
<SwitchField
field="config.wantAssertionsEncrypted"
label="wantAssertionsEncrypted"
isReadOnly={readOnly}
/>
<SwitchField
field="config.forceAuthn"
label="forceAuthentication"
isReadOnly={readOnly}
/>
<SwitchField
field="config.validateSignature"
label="validateSignature"
isReadOnly={readOnly}
/>
{validateSignature === "true" && (
<TextField
field="config.signingCertificate"
label="validatingX509Certs"
isReadOnly={readOnly}
/>
)}
<SwitchField
field="config.signSpMetadata"
label="signServiceProviderMetadata"
isReadOnly={readOnly}
/>
<SwitchField
field="config.passSubject"
label="passSubject"
isReadOnly={readOnly}
/>
<FormGroup
label={t("allowedClockSkew")}
labelIcon={
<HelpItem
helpText={th("allowedClockSkew")}
forLabel={t("allowedClockSkew")}
forID="allowedClockSkew"
/>
}
fieldId="allowedClockSkew"
helperTextInvalid={t("common:required")}
>
<TextInput
type="text"
id="allowedClockSkew"
name="config.allowedClockSkew"
isReadOnly={readOnly}
/>
</FormGroup>
</div>
);
};
export const DescriptorSettings = ({ readOnly }: DescriptorSettingsProps) => {
const { t } = useTranslation("identity-providers");
const [isExpanded, setIsExpanded] = useState(false);
return readOnly ? (
<ExpandableSection
className="keycloak__discovery-settings__metadata"
toggleText={isExpanded ? t("hideMetaData") : t("showMetaData")}
onToggle={(isOpen) => setIsExpanded(isOpen)}
isExpanded={isExpanded}
>
<Fields readOnly={readOnly} />
</ExpandableSection>
) : (
<Fields readOnly={readOnly} />
);
};

View file

@ -29,8 +29,11 @@ import { useRealm } from "../../context/realm-context/RealmContext";
import { KeycloakTabs } from "../../components/keycloak-tabs/KeycloakTabs"; import { KeycloakTabs } from "../../components/keycloak-tabs/KeycloakTabs";
import { ExtendedNonDiscoverySettings } from "./ExtendedNonDiscoverySettings"; import { ExtendedNonDiscoverySettings } from "./ExtendedNonDiscoverySettings";
import { DiscoverySettings } from "./DiscoverySettings"; import { DiscoverySettings } from "./DiscoverySettings";
import { DescriptorSettings } from "./DescriptorSettings";
import { OIDCGeneralSettings } from "./OIDCGeneralSettings"; import { OIDCGeneralSettings } from "./OIDCGeneralSettings";
import { SamlGeneralSettings } from "./SamlGeneralSettings";
import { OIDCAuthentication } from "./OIDCAuthentication"; import { OIDCAuthentication } from "./OIDCAuthentication";
import { ReqAuthnConstraints } from "./ReqAuthnConstraintsSettings";
type HeaderProps = { type HeaderProps = {
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
@ -133,12 +136,19 @@ export const DetailSettings = () => {
}); });
const sections = [t("generalSettings"), t("advancedSettings")]; const sections = [t("generalSettings"), t("advancedSettings")];
const isOIDC = id.includes("oidc"); const isOIDC = id.includes("oidc");
const isSAML = id.includes("saml");
if (isOIDC) { if (isOIDC) {
sections.splice(1, 0, t("oidcSettings")); sections.splice(1, 0, t("oidcSettings"));
} }
if (isSAML) {
sections.splice(1, 0, t("samlSettings"));
sections.splice(2, 0, t("reqAuthnConstraints"));
}
return ( return (
<> <>
<DeleteConfirm /> <DeleteConfirm />
@ -170,8 +180,11 @@ export const DetailSettings = () => {
isHorizontal isHorizontal
onSubmit={handleSubmit(save)} onSubmit={handleSubmit(save)}
> >
{!isOIDC && <GeneralSettings create={false} id={id} />} {!isOIDC && !isSAML && (
<GeneralSettings create={false} id={id} />
)}
{isOIDC && <OIDCGeneralSettings id={id} />} {isOIDC && <OIDCGeneralSettings id={id} />}
{isSAML && <SamlGeneralSettings id={id} />}
</FormAccess> </FormAccess>
{isOIDC && ( {isOIDC && (
<> <>
@ -183,12 +196,21 @@ export const DetailSettings = () => {
<ExtendedNonDiscoverySettings /> <ExtendedNonDiscoverySettings />
</> </>
)} )}
{isSAML && <DescriptorSettings readOnly={false} />}
<FormAccess <FormAccess
role="manage-identity-providers" role="manage-identity-providers"
isHorizontal isHorizontal
onSubmit={handleSubmit(save)} onSubmit={handleSubmit(save)}
> >
<AdvancedSettings isOIDC={isOIDC} /> <ReqAuthnConstraints />
</FormAccess>
<FormAccess
role="manage-identity-providers"
isHorizontal
onSubmit={handleSubmit(save)}
>
<AdvancedSettings isOIDC={isOIDC} isSAML={isSAML} />
<ActionGroup className="keycloak__form_actions"> <ActionGroup className="keycloak__form_actions">
<Button data-testid={"save"} type="submit"> <Button data-testid={"save"} type="submit">
{t("common:save")} {t("common:save")}

View file

@ -84,7 +84,7 @@ export const OpenIdConnectSettings = () => {
return ( return (
<> <>
<Title headingLevel="h4" size="xl" className="kc-form-panel__title"> <Title headingLevel="h4" size="xl" className="kc-form-panel__title">
{t("OpenID Connect settings")} {t("oidcSettings")}
</Title> </Title>
<FormGroup <FormGroup
label={t("useDiscoveryEndpoint")} label={t("useDiscoveryEndpoint")}

View file

@ -0,0 +1,75 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Controller, useFormContext } from "react-hook-form";
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from "@patternfly/react-core";
import { TextField } from "../component/TextField";
import { HelpItem } from "../../components/help-enabler/HelpItem";
const comparisonValues = ["Exact", "Minimum", "Maximum", "Better"];
export const ReqAuthnConstraints = () => {
const { t } = useTranslation("identity-providers");
const { control } = useFormContext();
const [syncModeOpen, setSyncModeOpen] = useState(false);
return (
<>
<FormGroup
label={t("comparison")}
labelIcon={
<HelpItem
helpText="identity-providers-help:comparison"
forLabel={t("comparison")}
forID="comparison"
/>
}
fieldId="comparison"
>
<Controller
name="config.comparison"
defaultValue={comparisonValues[0]}
control={control}
render={({ onChange, value }) => (
<Select
toggleId="comparison"
required
direction="up"
onToggle={() => setSyncModeOpen(!syncModeOpen)}
onSelect={(_, value) => {
onChange(value.toString());
setSyncModeOpen(false);
}}
selections={value}
variant={SelectVariant.single}
aria-label={t("syncMode")}
isOpen={syncModeOpen}
>
{comparisonValues.map((option) => (
<SelectOption
selected={option === value}
key={option}
value={option}
>
{t(option)}
</SelectOption>
))}
</Select>
)}
/>
</FormGroup>
<TextField
field="config.authnContextClassRefs"
label="authnContextClassRefs"
/>
<TextField
field="config.authnContextDeclRefs"
label="authnContextDeclRefs"
/>
</>
);
};

View file

@ -0,0 +1,213 @@
import React, { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import {
FormGroup,
Switch,
TextInput,
Title,
ValidatedOptions,
} from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../context/auth/AdminClient";
import type IdentityProviderRepresentation from "keycloak-admin/lib/defs/identityProviderRepresentation";
import { JsonFileUpload } from "../../components/json-file-upload/JsonFileUpload";
import { useRealm } from "../../context/realm-context/RealmContext";
import { DescriptorSettings } from "./DescriptorSettings";
import { getBaseUrl } from "../../util";
type Result = IdentityProviderRepresentation & {
error: string;
};
export const SamlConnectSettings = () => {
const { t } = useTranslation("identity-providers");
const id = "saml";
const adminClient = useAdminClient();
const { realm } = useRealm();
const { setValue, register, errors } = useFormContext();
const [descriptor, setDescriptor] = useState(true);
const [entityUrl, setEntityUrl] = useState("");
const [descriptorUrl, setDescriptorUrl] = useState("");
const [discovering, setDiscovering] = useState(false);
const [discoveryResult, setDiscoveryResult] = useState<Result>();
const defaultEntityUrl = `${getBaseUrl(adminClient)}realms/${realm}`;
const setupForm = (result: IdentityProviderRepresentation) => {
Object.entries(result).map(([key, value]) =>
setValue(`config.${key}`, value)
);
};
useEffect(() => {
if (!discovering) {
return;
}
setDiscovering(!!entityUrl);
if (!entityUrl) {
return;
}
(async () => {
let result;
try {
result = await adminClient.identityProviders.importFromUrl({
providerId: id,
fromUrl: entityUrl,
});
} catch (error) {
result = { error };
}
setDiscoveryResult(result as Result);
setupForm(result);
setDiscovering(false);
})();
}, [discovering]);
const fileUpload = async (obj: object) => {
if (obj) {
const formData = new FormData();
formData.append("providerId", id);
formData.append("file", new Blob([JSON.stringify(obj)]));
try {
const response = await fetch(
`${getBaseUrl(
adminClient
)}admin/realms/${realm}/identity-provider/import-config`,
{
method: "POST",
body: formData,
headers: {
Authorization: `bearer ${await adminClient.getAccessToken()}`,
},
}
);
const result = await response.json();
setupForm(result);
} catch (error) {
setDiscoveryResult({ error });
}
}
};
return (
<>
<Title headingLevel="h4" size="xl" className="kc-form-panel__title">
{t("samlSettings")}
</Title>
<FormGroup
label={t("serviceProviderEntityId")}
fieldId="kc-service-provider-entity-id"
labelIcon={
<HelpItem
helpText="identity-providers-help:serviceProviderEntityId"
forLabel={t("serviceProviderEntityId")}
forID="kc-service-provider-entity-id"
/>
}
isRequired
>
<TextInput
type="text"
name="config.entityId"
data-testid="serviceProviderEntityId"
id="kc-service-provider-entity-id"
value={entityUrl || defaultEntityUrl}
onChange={setEntityUrl}
ref={register({ required: true })}
/>
</FormGroup>
<FormGroup
label={t("useEntityDescriptor")}
fieldId="kc-use-entity-descriptor"
labelIcon={
<HelpItem
helpText="identity-providers-help:useEntityDescriptor"
forLabel={t("useEntityDescriptor")}
forID="kc-use-entity-descriptor-switch"
/>
}
>
<Switch
id="kc-use-entity-descriptor-switch"
label={t("common:on")}
data-testid="useEntityDescriptor"
labelOff={t("common:off")}
isChecked={descriptor}
onChange={setDescriptor}
/>
</FormGroup>
{descriptor && (
<FormGroup
label={t("samlEntityDescriptor")}
fieldId="kc-saml-entity-descriptor"
labelIcon={
<HelpItem
helpText="identity-providers-help:samlEntityDescriptor"
forLabel={t("samlEntityDescriptor")}
forID="kc-saml-entity-descriptor"
/>
}
isRequired
>
<TextInput
type="text"
name="samlEntityDescriptor"
data-testid="samlEntityDescriptor"
id="kc-saml-entity-descriptor"
value={descriptorUrl}
onChange={setDescriptorUrl}
ref={register({ required: true })}
validated={
errors.samlEntityDescriptor
? ValidatedOptions.error
: ValidatedOptions.default
}
/>
</FormGroup>
)}
{!descriptor && (
<FormGroup
label={t("importConfig")}
fieldId="kc-import-config"
labelIcon={
<HelpItem
helpText="identity-providers-help:importConfig"
forLabel={t("importConfig")}
forID="kc-import-config"
/>
}
validated={discoveryResult?.error ? "error" : "default"}
helperTextInvalid={discoveryResult?.error?.toString()}
>
<JsonFileUpload
id="kc-import-config"
helpText="identity-providers-help:jsonFileUpload"
hideDefaultPreview
unWrap
validated={discoveryResult?.error ? "error" : "default"}
onChange={(value) => fileUpload(value)}
/>
</FormGroup>
)}
{descriptor && discoveryResult && !discoveryResult.error && (
<DescriptorSettings readOnly={true} />
)}
{!descriptor && <DescriptorSettings readOnly={false} />}
</>
);
};

View file

@ -0,0 +1,54 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useFormContext } from "react-hook-form";
import { FormGroup, TextInput, ValidatedOptions } from "@patternfly/react-core";
import { HelpItem } from "../../components/help-enabler/HelpItem";
import { RedirectUrl } from "../component/RedirectUrl";
import { TextField } from "../component/TextField";
import { DisplayOrder } from "../component/DisplayOrder";
export const SamlGeneralSettings = ({ id }: { id: string }) => {
const { t } = useTranslation("identity-providers");
const { t: th } = useTranslation("identity-providers-help");
const { register, errors } = useFormContext();
return (
<>
<RedirectUrl id={id} />
<FormGroup
label={t("alias")}
labelIcon={
<HelpItem
helpText={th("alias")}
forLabel={t("alias")}
forID="alias"
/>
}
fieldId="alias"
isRequired
validated={
errors.alias ? ValidatedOptions.error : ValidatedOptions.default
}
helperTextInvalid={t("common:required")}
>
<TextInput
isRequired
type="text"
id="alias"
data-testid="alias"
name="alias"
validated={
errors.alias ? ValidatedOptions.error : ValidatedOptions.default
}
ref={register({ required: true })}
/>
</FormGroup>
<TextField field="displayName" label="displayName" />
<DisplayOrder />
</>
);
};

View file

@ -45,6 +45,8 @@ export default {
"The client authentication method (cfr. https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication). In case of JWT signed with private key, the realm private key is used.", "The client authentication method (cfr. https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication). In case of JWT signed with private key, the realm private key is used.",
storeTokens: storeTokens:
"Enable/disable if tokens must be stored after authenticating users.", "Enable/disable if tokens must be stored after authenticating users.",
storedTokensReadable:
"Enable/disable if new users can read any stored tokens. This assigns the broker.read-token role.",
trustEmail: trustEmail:
"If enabled, email provided by this provider is not verified even if verification is enabled for the realm.", "If enabled, email provided by this provider is not verified even if verification is enabled for the realm.",
accountLinkingOnly: accountLinkingOnly:
@ -57,5 +59,48 @@ export default {
'Alias of authentication flow, which is triggered after each login with this identity provider. Useful if you want additional verification of each user authenticated with this identity provider (for example OTP). Leave this to "None" if you need no any additional authenticators to be triggered after login with this identity provider. Also note that authenticator implementations must assume that user is already set in ClientSession as identity provider already set it.', 'Alias of authentication flow, which is triggered after each login with this identity provider. Useful if you want additional verification of each user authenticated with this identity provider (for example OTP). Leave this to "None" if you need no any additional authenticators to be triggered after login with this identity provider. Also note that authenticator implementations must assume that user is already set in ClientSession as identity provider already set it.',
syncMode: syncMode:
"Default sync mode for all mappers. The sync mode determines when user data will be synced using the mappers. Possible values are: 'legacy' to keep the behaviour before this option was introduced, 'import' to only import the user once during first login of the user with this identity provider, 'force' to always update the user during every login with this identity provider.", "Default sync mode for all mappers. The sync mode determines when user data will be synced using the mappers. Possible values are: 'legacy' to keep the behaviour before this option was introduced, 'import' to only import the user once during first login of the user with this identity provider, 'force' to always update the user during every login with this identity provider.",
serviceProviderEntityId:
"The Entity ID that will be used to uniquely identify this SAML Service Provider.",
useEntityDescriptor:
"Import metadata from a remote IDP SAML entity descriptor.",
samlEntityDescriptor:
"Allows you to load external IDP metadata from a config file or to download it from a URL.",
ssoServiceUrl:
"The Url that must be used to send authentication requests (SAML AuthnRequest).",
singleLogoutServiceUrl:
"The Url that must be used to send logout requests.",
nameIdPolicyFormat:
"Specifies the URI reference corresponding to a name identifier format.",
principalType:
"Way to identify and track external users from the assertion. Default is using Subject NameID, alternatively you can set up identifying attribute.",
httpPostBindingResponse:
"Indicates whether to respond to requests using HTTP-POST binding. If false, HTTP-REDIRECT binding will be used.",
httpPostBindingAuthnRequest:
"Indicates whether the AuthnRequest must be sent using HTTP-POST binding. If false, HTTP-REDIRECT binding will be used.",
httpPostBindingLogout:
"Indicates whether to respond to requests using HTTP-POST binding. If false, HTTP-REDIRECT binding will be used.",
wantAuthnRequestsSigned:
"Indicates whether the identity provider expects a signed AuthnRequest.",
signatureAlgorithm: "The signature algorithm to use to sign documents.",
samlSignatureKeyName:
"Signed SAML documents contain identification of signing key in KeyName element. For Keycloak / RH-SSO counterparty, use KEY_ID, for MS AD FS use CERT_SUBJECT, for others check and use NONE if no other option works.",
wantAssertionsSigned:
"Indicates whether this service provider expects a signed Assertion.",
wantAssertionsEncrypted:
"Indicates whether this service provider expects an encrypted Assertion.",
forceAuthentication:
"Indicates whether the identity provider must authenticate the presenter directly rather than rely on a previous security context.",
validateSignatures:
"Enable/disable signature validation of SAML responses.",
validatingX509Certs:
"The certificate in PEM format that must be used to check for signatures. Multiple certificates can be entered, separated by comma (,).",
signServiceProviderMetadata:
"Enable/disable signature of the provider SAML metadata.",
passSubject:
"During login phase, forward an optional login_hint query parameter to SAML AuthnRequest's Subject.",
comparison:
'Specifies the comparison method used to evaluate the requested context classes or statements. The default is "Exact".',
authnContextClassRefs: "Ordered list of requested AuthnContext ClassRefs.",
authnContextDeclRefs: "Ordered list of requested AuthnContext DeclRefs.",
}, },
}; };

View file

@ -7,6 +7,7 @@ export default {
addProvider: "Add provider", addProvider: "Add provider",
addKeycloakOpenIdProvider: "Add Keycloak OpenID Connect provider", addKeycloakOpenIdProvider: "Add Keycloak OpenID Connect provider",
addOpenIdProvider: "Add OpenID Connect provider", addOpenIdProvider: "Add OpenID Connect provider",
addSamlProvider: "Add SAML provider",
manageDisplayOrder: "Manage display order", manageDisplayOrder: "Manage display order",
deleteProvider: "Delete provider?", deleteProvider: "Delete provider?",
deleteConfirm: deleteConfirm:
@ -42,6 +43,35 @@ export default {
displayName: "Display name", displayName: "Display name",
useDiscoveryEndpoint: "Use discovery endpoint", useDiscoveryEndpoint: "Use discovery endpoint",
discoveryEndpoint: "Discovery endpoint", discoveryEndpoint: "Discovery endpoint",
useEntityDescriptor: "Use entity descriptor",
samlEntityDescriptor: "SAML entity descriptor",
ssoServiceUrl: "Single Sign-On service URL",
singleLogoutServiceUrl: "Single logout service URL",
nameIdPolicyFormat: "NameID policy format",
persistent: "Persistent",
transient: "Transient",
email: "Email",
kerberos: "Kerberos",
x509: "X.509 Subject Name",
windowsDomainQN: "Windows Domain Qualified Name",
unspecified: "Unspecified",
principalType: "Principal type",
subjectNameId: "Subject NameID",
attributeName: "Attribute [Name]",
attributeFriendlyName: "Attribute [Friendly Name]",
httpPostBindingResponse: "HTTP-POST binding response",
httpPostBindingAuthnRequest: "HTTP-POST binding for AuthnRequest",
httpPostBindingLogout: "HTTP-POST binding logout",
wantAuthnRequestsSigned: "Want AuthnRequests signed",
signatureAlgorithm: "Signature algorithm",
samlSignatureKeyName: "SAML signature key name",
wantAssertionsSigned: "Want Assertions signed",
wantAssertionsEncrypted: "Want Assertions encrypted",
forceAuthentication: "Force authentication",
validatingX509Certs: "Validating X509 certificates",
signServiceProviderMetadata: "Sign service provider metadata",
passSubject: "Pass subject",
serviceProviderEntityId: "Service provider entity ID",
importConfig: "Import config from file", importConfig: "Import config from file",
showMetaData: "Show metadata", showMetaData: "Show metadata",
hideMetaData: "Hide metadata", hideMetaData: "Hide metadata",
@ -80,9 +110,18 @@ export default {
allowedClockSkew: "Allowed clock skew", allowedClockSkew: "Allowed clock skew",
forwardParameters: "Forwarded query parameters", forwardParameters: "Forwarded query parameters",
generalSettings: "General settings", generalSettings: "General settings",
oidcSettings: "OpenId Connect settings", oidcSettings: "OpenID Connect settings",
samlSettings: "SAML settings",
advancedSettings: "Advanced settings", advancedSettings: "Advanced settings",
reqAuthnConstraints: "Requested AuthnContext Constraints",
keyID: "KEY_ID",
NONE: "NONE",
certSubject: "CERT_SUBJECT",
storeTokens: "Store tokens", storeTokens: "Store tokens",
storedTokensReadable: "Stored tokens readable",
comparison: "Comparison",
authnContextClassRefs: "AuthnContext ClassRefs",
authnContextDeclRefs: "AuthnContext DeclRefs",
trustEmail: "Trust Email", trustEmail: "Trust Email",
accountLinkingOnly: "Account linking only", accountLinkingOnly: "Account linking only",
hideOnLoginPage: "Hide on login page", hideOnLoginPage: "Hide on login page",

View file

@ -2,12 +2,14 @@ import type { RouteDef } from "../route-config";
import { IdentityProviderRoute } from "./routes/IdentityProvider"; import { IdentityProviderRoute } from "./routes/IdentityProvider";
import { IdentityProviderKeycloakOidcRoute } from "./routes/IdentityProviderKeycloakOidc"; import { IdentityProviderKeycloakOidcRoute } from "./routes/IdentityProviderKeycloakOidc";
import { IdentityProviderOidcRoute } from "./routes/IdentityProviderOidc"; import { IdentityProviderOidcRoute } from "./routes/IdentityProviderOidc";
import { IdentityProviderSamlRoute } from "./routes/IdentityProviderSaml";
import { IdentityProvidersRoute } from "./routes/IdentityProviders"; import { IdentityProvidersRoute } from "./routes/IdentityProviders";
import { IdentityProviderTabRoute } from "./routes/IdentityProviderTab"; import { IdentityProviderTabRoute } from "./routes/IdentityProviderTab";
const routes: RouteDef[] = [ const routes: RouteDef[] = [
IdentityProvidersRoute, IdentityProvidersRoute,
IdentityProviderOidcRoute, IdentityProviderOidcRoute,
IdentityProviderSamlRoute,
IdentityProviderKeycloakOidcRoute, IdentityProviderKeycloakOidcRoute,
IdentityProviderRoute, IdentityProviderRoute,
IdentityProviderTabRoute, IdentityProviderTabRoute,

View file

@ -0,0 +1,19 @@
import type { LocationDescriptorObject } from "history";
import { generatePath } from "react-router-dom";
import type { RouteDef } from "../../route-config";
import { AddSamlConnect } from "../add/AddSamlConnect";
export type IdentityProviderSamlParams = { realm: string };
export const IdentityProviderSamlRoute: RouteDef = {
path: "/:realm/identity-providers/saml",
component: AddSamlConnect,
breadcrumb: (t) => t("identity-providers:addSamlProvider"),
access: "manage-identity-providers",
};
export const toIdentityProviderSaml = (
params: IdentityProviderSamlParams
): LocationDescriptorObject => ({
pathname: generatePath(IdentityProviderSamlRoute.path, params),
});