Finish the policy pages of the authentication section (#1713)
* added WebauthnPolicy tab to Plicies * change for passwordless * added tests * removed use of useToggle
This commit is contained in:
parent
23b11eb879
commit
33a1769c39
10 changed files with 634 additions and 62 deletions
68
cypress/integration/authentication_policies.spec.ts
Normal file
68
cypress/integration/authentication_policies.spec.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { keycloakBefore } from "../support/util/keycloak_before";
|
||||
import Masthead from "../support/pages/admin_console/Masthead";
|
||||
import LoginPage from "../support/pages/LoginPage";
|
||||
import SidebarPage from "../support/pages/admin_console/SidebarPage";
|
||||
import OTPPolicies from "../support/pages/admin_console/manage/authentication/OTPPolicies";
|
||||
import WebAuthnPolicies from "../support/pages/admin_console/manage/authentication/WebAuthnPolicies";
|
||||
|
||||
describe("Policies", () => {
|
||||
const masthead = new Masthead();
|
||||
const loginPage = new LoginPage();
|
||||
const sidebarPage = new SidebarPage();
|
||||
|
||||
describe("OTP policies tab", () => {
|
||||
const otpPoliciesPage = new OTPPolicies();
|
||||
beforeEach(() => {
|
||||
keycloakBefore();
|
||||
loginPage.logIn();
|
||||
sidebarPage.goToAuthentication();
|
||||
otpPoliciesPage.goToTab();
|
||||
});
|
||||
|
||||
it("should change to hotp", () => {
|
||||
otpPoliciesPage.checkSupportedActions("FreeOTP, Google Authenticator");
|
||||
otpPoliciesPage.setPolicyType("hotp").increaseInitialCounter().save();
|
||||
masthead.checkNotificationMessage("OTP policy successfully updated");
|
||||
otpPoliciesPage.checkSupportedActions("FreeOTP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webauthn policies tabs", () => {
|
||||
const webauthnPage = new WebAuthnPolicies();
|
||||
beforeEach(() => {
|
||||
keycloakBefore();
|
||||
loginPage.logIn();
|
||||
sidebarPage.goToAuthentication();
|
||||
});
|
||||
|
||||
it("should fill webauthn settings", () => {
|
||||
webauthnPage.goToTab();
|
||||
webauthnPage.fillSelects({
|
||||
webAuthnPolicyAttestationConveyancePreference: "Indirect",
|
||||
webAuthnPolicyRequireResidentKey: "Yes",
|
||||
webAuthnPolicyUserVerificationRequirement: "Preferred",
|
||||
});
|
||||
webauthnPage.webAuthnPolicyCreateTimeout(30).save();
|
||||
masthead.checkNotificationMessage(
|
||||
"Updated webauthn policies successfully"
|
||||
);
|
||||
});
|
||||
|
||||
it("should fill webauthn passwordless settings", () => {
|
||||
webauthnPage.goToPasswordlessTab();
|
||||
webauthnPage
|
||||
.fillSelects(
|
||||
{
|
||||
webAuthnPolicyAttestationConveyancePreference: "Indirect",
|
||||
webAuthnPolicyRequireResidentKey: "Yes",
|
||||
webAuthnPolicyUserVerificationRequirement: "Preferred",
|
||||
},
|
||||
true
|
||||
)
|
||||
.save();
|
||||
masthead.checkNotificationMessage(
|
||||
"Updated webauthn policies successfully"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -8,7 +8,6 @@ import FlowDetails from "../support/pages/admin_console/manage/authentication/Fl
|
|||
import RequiredActions from "../support/pages/admin_console/manage/authentication/RequiredActions";
|
||||
import AdminClient from "../support/util/AdminClient";
|
||||
import PasswordPolicies from "../support/pages/admin_console/manage/authentication/PasswordPolicies";
|
||||
import OTPPolicies from "../support/pages/admin_console/manage/authentication/OTPPolicies";
|
||||
|
||||
describe("Authentication test", () => {
|
||||
const loginPage = new LoginPage();
|
||||
|
@ -184,21 +183,4 @@ describe("Authentication test", () => {
|
|||
passwordPoliciesPage.shouldShowEmptyState();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OTP policies tab", () => {
|
||||
const otpPoliciesPage = new OTPPolicies();
|
||||
beforeEach(() => {
|
||||
keycloakBefore();
|
||||
loginPage.logIn();
|
||||
sidebarPage.goToAuthentication();
|
||||
otpPoliciesPage.goToTab();
|
||||
});
|
||||
|
||||
it("should change to hotp", () => {
|
||||
otpPoliciesPage.checkSupportedActions("FreeOTP, Google Authenticator");
|
||||
otpPoliciesPage.setPolicyType("hotp").increaseInitialCounter().save();
|
||||
masthead.checkNotificationMessage("OTP policy successfully updated");
|
||||
otpPoliciesPage.checkSupportedActions("FreeOTP");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
export default class WebAuthnPolicies {
|
||||
webAuthnPolicyCreateTimeout(value: number) {
|
||||
cy.findByTestId("webAuthnPolicyCreateTimeout").type(String(value));
|
||||
return this;
|
||||
}
|
||||
goToTab() {
|
||||
cy.get("#pf-tab-policies-policies")
|
||||
.click()
|
||||
.get("#pf-tab-3-webauthnPolicy")
|
||||
.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
goToPasswordlessTab() {
|
||||
cy.get("#pf-tab-policies-policies")
|
||||
.click()
|
||||
.get("#pf-tab-4-webauthnPasswordlessPolicy")
|
||||
.click();
|
||||
return this;
|
||||
}
|
||||
|
||||
fillSelects(data: Record<string, string>, isPasswordLess: boolean = false) {
|
||||
for (const prop of Object.keys(data)) {
|
||||
cy.get(
|
||||
`#${
|
||||
isPasswordLess ? prop.replace("Policy", "PolicyPasswordless") : prop
|
||||
}`
|
||||
)
|
||||
.click()
|
||||
.parent()
|
||||
.contains(data[prop])
|
||||
.click();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
save() {
|
||||
cy.findByTestId("save").click();
|
||||
return this;
|
||||
}
|
||||
}
|
|
@ -24,5 +24,28 @@ export default {
|
|||
"How many seconds should an OTP token be valid? Defaults to 30 seconds.",
|
||||
supportedActions:
|
||||
"Applications that are known to work with the current OTP policy",
|
||||
webauthnIntro: "What is this form used for?",
|
||||
webAuthnPolicyFormHelp:
|
||||
"Policy for WebAuthn authentication. This one will be used by 'WebAuthn Register' required action and 'WebAuthn Authenticator' authenticator. Typical usage is, when WebAuthn will be used for the two-factor authentication.",
|
||||
webAuthnPolicyPasswordlessFormHelp:
|
||||
"Policy for passwordless WebAuthn authentication. This one will be used by 'Webauthn Register Passwordless' required action and 'WebAuthn Passwordless Authenticator' authenticator. Typical usage is, when WebAuthn will be used as first-factor authentication. Having both 'WebAuthn Policy' and 'WebAuthn Passwordless Policy' allows to use WebAuthn as both first factor and second factor authenticator in the same realm.",
|
||||
webAuthnPolicySignatureAlgorithms:
|
||||
"What signature algorithms should be used for Authentication Assertion.",
|
||||
webAuthnPolicyRpId:
|
||||
"This is ID as WebAuthn Relying Party. It must be origin's effective domain.",
|
||||
webAuthnPolicyAttestationConveyancePreference:
|
||||
"Communicates to an authenticator the preference of how to generate an attestation statement.",
|
||||
webAuthnPolicyAuthenticatorAttachment:
|
||||
"Communicates to an authenticator an acceptable attachment pattern.",
|
||||
webAuthnPolicyRequireResidentKey:
|
||||
"It tells an authenticator create a public key credential as Resident Key or not.",
|
||||
webAuthnPolicyUserVerificationRequirement:
|
||||
"Communicates to an authenticator to confirm actually verifying a user.",
|
||||
webAuthnPolicyCreateTimeout:
|
||||
"Timeout value for creating user's public key credential in seconds. if set to 0, this timeout option is not adapted.",
|
||||
webAuthnPolicyAvoidSameAuthenticatorRegister:
|
||||
"Avoid registering the authenticator that has already been registered.",
|
||||
webAuthnPolicyAcceptableAaguids:
|
||||
"The list of AAGUID of which an authenticator can be registered.",
|
||||
},
|
||||
};
|
||||
|
|
|
@ -33,6 +33,44 @@ export default {
|
|||
supportedActions: "Supported actions",
|
||||
updateOtpSuccess: "OTP policy successfully updated",
|
||||
updateOtpError: "Could not update OTP policy: {{error}}",
|
||||
webAuthnPolicySignatureAlgorithms: "Signature algorithms",
|
||||
webAuthnPolicyRpId: "Relying party ID",
|
||||
webAuthnPolicyAttestationConveyancePreference:
|
||||
"Attestation conveyance preference",
|
||||
attestationPreference: {
|
||||
"not specified": "Not specified",
|
||||
none: "None",
|
||||
indirect: "Indirect",
|
||||
direct: "Direct",
|
||||
},
|
||||
webAuthnPolicyAuthenticatorAttachment: "Authenticator Attachment",
|
||||
authenticatorAttachment: {
|
||||
"not specified": "Not specified",
|
||||
platform: "Platform",
|
||||
"cross-platform": "Cross platform",
|
||||
},
|
||||
webAuthnPolicyRequireResidentKey: "Require resident key",
|
||||
residentKey: {
|
||||
"not specified": "Not specified",
|
||||
Yes: "Yes",
|
||||
No: "No",
|
||||
},
|
||||
webAuthnPolicyUserVerificationRequirement: "User verification requirement",
|
||||
userVerify: {
|
||||
"not specified": "Not specified",
|
||||
required: "Required",
|
||||
preferred: "Preferred",
|
||||
discouraged: "Discouraged",
|
||||
},
|
||||
webAuthnPolicyCreateTimeout: "Timeout",
|
||||
webAuthnPolicyCreateTimeoutHint:
|
||||
"Timeout needs to be between 0 seconds and 8 hours",
|
||||
webAuthnPolicyAvoidSameAuthenticatorRegister:
|
||||
"Avoid same authenticator registration",
|
||||
webAuthnPolicyAcceptableAaguids: "Acceptable AAGUIDs",
|
||||
addAaguids: "Add AAGUID",
|
||||
webAuthnUpdateSuccess: "Updated webauthn policies successfully",
|
||||
webAuthnUpdateError: "Could not update webauthn policies due to {{error}}",
|
||||
flowName: "Flow name",
|
||||
searchForFlow: "Search for flow",
|
||||
usedBy: "Used by",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import {
|
||||
|
@ -21,7 +21,7 @@ import { FormAccess } from "../../components/form-access/FormAccess";
|
|||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import useToggle from "../../utils/useToggle";
|
||||
import { TimeSelector } from "../../components/time-selector/TimeSelector";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
|
||||
|
@ -31,10 +31,16 @@ const POLICY_TYPES = ["totp", "hotp"] as const;
|
|||
const OTP_HASH_ALGORITHMS = ["SHA1", "SHA256", "SHA512"] as const;
|
||||
const NUMBER_OF_DIGITS = [6, 8] as const;
|
||||
|
||||
export const OtpPolicy = () => {
|
||||
type OtpPolicyProps = {
|
||||
realm: RealmRepresentation;
|
||||
realmUpdated: (realm: RealmRepresentation) => void;
|
||||
};
|
||||
|
||||
export const OtpPolicy = ({ realm, realmUpdated }: OtpPolicyProps) => {
|
||||
const { t } = useTranslation("authentication");
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
errors,
|
||||
reset,
|
||||
handleSubmit,
|
||||
|
@ -45,7 +51,6 @@ export const OtpPolicy = () => {
|
|||
const { addAlert, addError } = useAlerts();
|
||||
|
||||
const [open, toggle] = useToggle();
|
||||
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||
|
||||
const otpType = useWatch<typeof POLICY_TYPES[number]>({
|
||||
name: "otpPolicyType",
|
||||
|
@ -53,14 +58,13 @@ export const OtpPolicy = () => {
|
|||
defaultValue: POLICY_TYPES[0],
|
||||
});
|
||||
|
||||
useFetch(
|
||||
() => adminClient.realms.findOne({ realm: realmName }),
|
||||
(realm) => {
|
||||
setRealm(realm);
|
||||
reset({ ...realm });
|
||||
},
|
||||
[]
|
||||
);
|
||||
const setupForm = (realm: RealmRepresentation) =>
|
||||
reset({
|
||||
...realm,
|
||||
otpSupportedApplications: realm.otpSupportedApplications?.join(", "),
|
||||
});
|
||||
|
||||
useEffect(() => setupForm(realm), []);
|
||||
|
||||
const save = async (realm: RealmRepresentation) => {
|
||||
try {
|
||||
|
@ -68,8 +72,8 @@ export const OtpPolicy = () => {
|
|||
const updatedRealm = await adminClient.realms.findOne({
|
||||
realm: realmName,
|
||||
});
|
||||
setRealm(updatedRealm);
|
||||
reset({ ...updatedRealm });
|
||||
realmUpdated(updatedRealm!);
|
||||
setupForm(updatedRealm!);
|
||||
addAlert(t("updateOtpSuccess"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("authentication:updateOtpError", error);
|
||||
|
@ -310,9 +314,12 @@ export const OtpPolicy = () => {
|
|||
>
|
||||
<TextInput
|
||||
id="supportedActions"
|
||||
name="otpSupportedApplications"
|
||||
ref={register({
|
||||
setValueAs: (value) => value.split(", "),
|
||||
})}
|
||||
data-testid="supportedActions"
|
||||
isReadOnly
|
||||
value={realm?.otpSupportedApplications?.join(", ")}
|
||||
/>
|
||||
</FormGroup>
|
||||
<ActionGroup>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
|
@ -23,10 +23,9 @@ import { PlusCircleIcon } from "@patternfly/react-icons";
|
|||
|
||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||
import type PasswordPolicyTypeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/passwordPolicyTypeRepresentation";
|
||||
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { parsePolicy, SubmittedValues } from "./util";
|
||||
|
@ -72,15 +71,22 @@ const PolicySelect = ({ onSelect, selectedPolicies }: PolicySelectProps) => {
|
|||
);
|
||||
};
|
||||
|
||||
export const PasswordPolicy = () => {
|
||||
type PasswordPolicyProps = {
|
||||
realm: RealmRepresentation;
|
||||
realmUpdated: (realm: RealmRepresentation) => void;
|
||||
};
|
||||
|
||||
export const PasswordPolicy = ({
|
||||
realm,
|
||||
realmUpdated,
|
||||
}: PasswordPolicyProps) => {
|
||||
const { t } = useTranslation("authentication");
|
||||
const { passwordPolicies } = useServerInfo();
|
||||
|
||||
const adminClient = useAdminClient();
|
||||
const { realm: realmName } = useRealm();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const { realm: realmName } = useRealm();
|
||||
|
||||
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||
const [rows, setRows] = useState<PasswordPolicyTypeRepresentation[]>([]);
|
||||
const onSelect = (row: PasswordPolicyTypeRepresentation) =>
|
||||
setRows([...rows, row]);
|
||||
|
@ -96,20 +102,7 @@ export const PasswordPolicy = () => {
|
|||
setRows(values);
|
||||
};
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const realm = await adminClient.realms.findOne({ realm: realmName });
|
||||
if (!realm) {
|
||||
throw new Error(t("common:notFound"));
|
||||
}
|
||||
return realm;
|
||||
},
|
||||
(realm) => {
|
||||
setRealm(realm);
|
||||
setupForm(realm);
|
||||
},
|
||||
[]
|
||||
);
|
||||
useEffect(() => setupForm(realm), []);
|
||||
|
||||
const save = async (values: SubmittedValues) => {
|
||||
const updatedRealm = {
|
||||
|
@ -118,17 +111,14 @@ export const PasswordPolicy = () => {
|
|||
};
|
||||
try {
|
||||
await adminClient.realms.update({ realm: realmName }, updatedRealm);
|
||||
setRealm(updatedRealm);
|
||||
realmUpdated(updatedRealm);
|
||||
setupForm(updatedRealm);
|
||||
addAlert(t("updatePasswordPolicySuccess"), AlertVariant.success);
|
||||
} catch (error: any) {
|
||||
addError("authentication:updatePasswordPolicyError", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!realm) {
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection variant="light" className="pf-u-p-0">
|
||||
{(rows.length !== 0 || realm.passwordPolicy) && (
|
||||
|
|
|
@ -2,12 +2,39 @@ import React, { useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Tab, Tabs, TabTitleText } from "@patternfly/react-core";
|
||||
|
||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||
import { useAdminClient, useFetch } from "../../context/auth/AdminClient";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { PasswordPolicy } from "./PasswordPolicy";
|
||||
import { OtpPolicy } from "./OtpPolicy";
|
||||
import { WebauthnPolicy } from "./WebauthnPolicy";
|
||||
import { KeycloakSpinner } from "../../components/keycloak-spinner/KeycloakSpinner";
|
||||
|
||||
export const Policies = () => {
|
||||
const { t } = useTranslation("authentication");
|
||||
const [subTab, setSubTab] = useState(1);
|
||||
const adminClient = useAdminClient();
|
||||
const { realm: realmName } = useRealm();
|
||||
const [realm, setRealm] = useState<RealmRepresentation>();
|
||||
|
||||
useFetch(
|
||||
async () => {
|
||||
const realm = await adminClient.realms.findOne({ realm: realmName });
|
||||
if (!realm) {
|
||||
throw new Error(t("common:notFound"));
|
||||
}
|
||||
return realm;
|
||||
},
|
||||
(realm) => {
|
||||
setRealm(realm);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (!realm) {
|
||||
return <KeycloakSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={subTab}
|
||||
|
@ -19,25 +46,29 @@ export const Policies = () => {
|
|||
eventKey={1}
|
||||
title={<TabTitleText>{t("passwordPolicy")}</TabTitleText>}
|
||||
>
|
||||
<PasswordPolicy />
|
||||
<PasswordPolicy realm={realm} realmUpdated={setRealm} />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="otpPolicy"
|
||||
eventKey={2}
|
||||
title={<TabTitleText>{t("otpPolicy")}</TabTitleText>}
|
||||
>
|
||||
<OtpPolicy />
|
||||
<OtpPolicy realm={realm} realmUpdated={setRealm} />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="webauthnPolicy"
|
||||
eventKey={3}
|
||||
title={<TabTitleText>{t("webauthnPolicy")}</TabTitleText>}
|
||||
></Tab>
|
||||
>
|
||||
<WebauthnPolicy realm={realm} realmUpdated={setRealm} />
|
||||
</Tab>
|
||||
<Tab
|
||||
id="webauthnPasswordlessPolicy"
|
||||
eventKey={4}
|
||||
title={<TabTitleText>{t("webauthnPasswordlessPolicy")}</TabTitleText>}
|
||||
></Tab>
|
||||
>
|
||||
<WebauthnPolicy realm={realm} realmUpdated={setRealm} isPasswordLess />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
|
381
src/authentication/policies/WebauthnPolicy.tsx
Normal file
381
src/authentication/policies/WebauthnPolicy.tsx
Normal file
|
@ -0,0 +1,381 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useForm,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionGroup,
|
||||
AlertVariant,
|
||||
Button,
|
||||
ButtonVariant,
|
||||
FormGroup,
|
||||
PageSection,
|
||||
Popover,
|
||||
Select,
|
||||
SelectOption,
|
||||
SelectVariant,
|
||||
Switch,
|
||||
Text,
|
||||
TextContent,
|
||||
TextInput,
|
||||
} from "@patternfly/react-core";
|
||||
import { QuestionCircleIcon } from "@patternfly/react-icons";
|
||||
|
||||
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
|
||||
import { convertFormValuesToObject, convertToFormValues } from "../../util";
|
||||
import { useAdminClient } from "../../context/auth/AdminClient";
|
||||
import { useRealm } from "../../context/realm-context/RealmContext";
|
||||
import { FormAccess } from "../../components/form-access/FormAccess";
|
||||
import { HelpItem } from "../../components/help-enabler/HelpItem";
|
||||
import { useHelp } from "../../components/help-enabler/HelpHeader";
|
||||
import { useAlerts } from "../../components/alert/Alerts";
|
||||
import { TimeSelector } from "../../components/time-selector/TimeSelector";
|
||||
import { MultiLineInput } from "../../components/multi-line-input/MultiLineInput";
|
||||
|
||||
import "./webauthn-policy.css";
|
||||
|
||||
const SIGNATURE_ALGORITHMS = [
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"RS1",
|
||||
] as const;
|
||||
const ATTESTATION_PREFERENCE = [
|
||||
"not specified",
|
||||
"none",
|
||||
"indirect",
|
||||
"direct",
|
||||
] as const;
|
||||
|
||||
const AUTHENTICATOR_ATTACHMENT = [
|
||||
"not specified",
|
||||
"platform",
|
||||
"cross-platform",
|
||||
] as const;
|
||||
|
||||
const RESIDENT_KEY_OPTIONS = ["not specified", "Yes", "No"] as const;
|
||||
|
||||
const USER_VERIFY = [
|
||||
"not specified",
|
||||
"required",
|
||||
"preferred",
|
||||
"discouraged",
|
||||
] as const;
|
||||
|
||||
type WeauthnSelectProps = {
|
||||
name: string;
|
||||
label: string;
|
||||
options: readonly string[];
|
||||
labelPrefix?: string;
|
||||
isMultiSelect?: boolean;
|
||||
};
|
||||
|
||||
const WebauthnSelect = ({
|
||||
name,
|
||||
label,
|
||||
options,
|
||||
labelPrefix,
|
||||
isMultiSelect = false,
|
||||
}: WeauthnSelectProps) => {
|
||||
const { t } = useTranslation("authentication");
|
||||
const { control } = useFormContext();
|
||||
|
||||
const [open, toggle] = useState(false);
|
||||
return (
|
||||
<FormGroup
|
||||
label={t(label)}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText={`authentication-help:${label}`}
|
||||
fieldLabelId={`authentication:${label}`}
|
||||
/>
|
||||
}
|
||||
fieldId={name}
|
||||
>
|
||||
<Controller
|
||||
name={name}
|
||||
defaultValue={options[0]}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Select
|
||||
toggleId={name}
|
||||
onToggle={toggle}
|
||||
onSelect={(_, selectedValue) => {
|
||||
if (isMultiSelect) {
|
||||
const changedValue = value.find(
|
||||
(item: string) => item === selectedValue
|
||||
)
|
||||
? value.filter((item: string) => item !== selectedValue)
|
||||
: [...value, selectedValue];
|
||||
onChange(changedValue);
|
||||
} else {
|
||||
onChange(selectedValue.toString());
|
||||
toggle(false);
|
||||
}
|
||||
}}
|
||||
selections={labelPrefix ? t(`${labelPrefix}.${value}`) : value}
|
||||
variant={
|
||||
isMultiSelect
|
||||
? SelectVariant.typeaheadMulti
|
||||
: SelectVariant.single
|
||||
}
|
||||
aria-label={t(name)}
|
||||
isOpen={open}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<SelectOption
|
||||
selected={option === value}
|
||||
key={option}
|
||||
value={option}
|
||||
>
|
||||
{labelPrefix ? t(`${labelPrefix}.${option}`) : option}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const MULTILINE_INPUTS = [
|
||||
"webAuthnPolicyAcceptableAaguids",
|
||||
"webAuthnPolicyPasswordlessAcceptableAaguids",
|
||||
];
|
||||
|
||||
type WebauthnPolicyProps = {
|
||||
realm: RealmRepresentation;
|
||||
realmUpdated: (realm: RealmRepresentation) => void;
|
||||
isPasswordLess?: boolean;
|
||||
};
|
||||
|
||||
export const WebauthnPolicy = ({
|
||||
realm,
|
||||
realmUpdated,
|
||||
isPasswordLess = false,
|
||||
}: WebauthnPolicyProps) => {
|
||||
const { t } = useTranslation("authentication");
|
||||
const adminClient = useAdminClient();
|
||||
const { addAlert, addError } = useAlerts();
|
||||
const { realm: realmName } = useRealm();
|
||||
const { enabled } = useHelp();
|
||||
const form = useForm({ mode: "onChange", shouldUnregister: false });
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
errors,
|
||||
handleSubmit,
|
||||
formState: { isDirty },
|
||||
} = form;
|
||||
|
||||
const namePrefix = isPasswordLess
|
||||
? "webAuthnPolicyPasswordless"
|
||||
: "webAuthnPolicy";
|
||||
|
||||
const setupForm = (realm: RealmRepresentation) =>
|
||||
convertToFormValues(realm, setValue, MULTILINE_INPUTS);
|
||||
|
||||
useEffect(() => setupForm(realm), []);
|
||||
|
||||
const save = async (realm: RealmRepresentation) => {
|
||||
const submittedRealm = convertFormValuesToObject(realm, MULTILINE_INPUTS);
|
||||
try {
|
||||
await adminClient.realms.update({ realm: realmName }, submittedRealm);
|
||||
realmUpdated(submittedRealm);
|
||||
setupForm(submittedRealm);
|
||||
addAlert(t("webAuthnUpdateSuccess"), AlertVariant.success);
|
||||
} catch (error) {
|
||||
addError("authentication:webAuthnUpdateError", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageSection variant="light">
|
||||
{enabled && (
|
||||
<Popover bodyContent={t(`authentication-help:${namePrefix}FormHelp`)}>
|
||||
<TextContent className="keycloak__webauthn_policies__intro">
|
||||
<Text>
|
||||
<QuestionCircleIcon /> {t("authentication-help:webauthnIntro")}
|
||||
</Text>
|
||||
</TextContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<FormAccess
|
||||
role="manage-realm"
|
||||
isHorizontal
|
||||
onSubmit={handleSubmit(save)}
|
||||
className="keycloak__webauthn_policies_authentication__form"
|
||||
>
|
||||
<FormGroup
|
||||
label={t("webAuthnPolicyRpEntityName")}
|
||||
fieldId="webAuthnPolicyRpEntityName"
|
||||
helperTextInvalid={t("common:required")}
|
||||
validated={errors.webAuthnPolicyRpEntityName ? "error" : "default"}
|
||||
isRequired
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="authentication-help:webAuthnPolicyRpEntityName"
|
||||
fieldLabelId="authentication:webAuthnPolicyRpEntityName"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
ref={register({ required: true })}
|
||||
name={`${namePrefix}RpEntityName`}
|
||||
id="webAuthnPolicyRpEntityName"
|
||||
data-testid="webAuthnPolicyRpEntityName"
|
||||
validated={errors.webAuthnPolicyRpEntityName ? "error" : "default"}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormProvider {...form}>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}SignatureAlgorithms`}
|
||||
label="webAuthnPolicySignatureAlgorithms"
|
||||
options={SIGNATURE_ALGORITHMS}
|
||||
isMultiSelect
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("webAuthnPolicyRpId")}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="authentication-help:webAuthnPolicyRpId"
|
||||
fieldLabelId="authentication:webAuthnPolicyRpId"
|
||||
/>
|
||||
}
|
||||
fieldId="webAuthnPolicyRpId"
|
||||
>
|
||||
<TextInput
|
||||
id="webAuthnPolicyRpId"
|
||||
name={`${namePrefix}RpId`}
|
||||
ref={register()}
|
||||
data-testid="webAuthnPolicyRpId
|
||||
"
|
||||
/>
|
||||
</FormGroup>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}AttestationConveyancePreference`}
|
||||
label="webAuthnPolicyAttestationConveyancePreference"
|
||||
options={ATTESTATION_PREFERENCE}
|
||||
labelPrefix="attestationPreference"
|
||||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}AuthenticatorAttachment`}
|
||||
label="webAuthnPolicyAuthenticatorAttachment"
|
||||
options={AUTHENTICATOR_ATTACHMENT}
|
||||
labelPrefix="authenticatorAttachment"
|
||||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}RequireResidentKey`}
|
||||
label="webAuthnPolicyRequireResidentKey"
|
||||
options={RESIDENT_KEY_OPTIONS}
|
||||
labelPrefix="residentKey"
|
||||
/>
|
||||
<WebauthnSelect
|
||||
name={`${namePrefix}UserVerificationRequirement`}
|
||||
label="webAuthnPolicyUserVerificationRequirement"
|
||||
options={USER_VERIFY}
|
||||
labelPrefix="userVerify"
|
||||
/>
|
||||
<FormGroup
|
||||
label={t("webAuthnPolicyCreateTimeout")}
|
||||
fieldId="webAuthnPolicyCreateTimeout"
|
||||
helperTextInvalid={t("webAuthnPolicyCreateTimeoutHint")}
|
||||
validated={errors.webAuthnPolicyCreateTimeout ? "error" : "default"}
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="authentication-help:webAuthnPolicyCreateTimeout"
|
||||
fieldLabelId="authentication:webAuthnPolicyCreateTimeout"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={`${namePrefix}CreateTimeout`}
|
||||
defaultValue={0}
|
||||
control={control}
|
||||
rules={{ min: 0, max: 31536 }}
|
||||
render={({ onChange, value }) => (
|
||||
<TimeSelector
|
||||
data-testid="webAuthnPolicyCreateTimeout"
|
||||
aria-label={t("webAuthnPolicyCreateTimeout")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
units={["seconds", "minutes", "hours"]}
|
||||
validated={
|
||||
errors.webAuthnPolicyCreateTimeout ? "error" : "default"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("webAuthnPolicyAvoidSameAuthenticatorRegister")}
|
||||
fieldId="webAuthnPolicyAvoidSameAuthenticatorRegister"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="authentication-help:webAuthnPolicyAvoidSameAuthenticatorRegister"
|
||||
fieldLabelId="authentication:webAuthnPolicyAvoidSameAuthenticatorRegister"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Controller
|
||||
name={`${namePrefix}AvoidSameAuthenticatorRegister`}
|
||||
defaultValue={false}
|
||||
control={control}
|
||||
render={({ onChange, value }) => (
|
||||
<Switch
|
||||
id="webAuthnPolicyAvoidSameAuthenticatorRegister"
|
||||
label={t("common:on")}
|
||||
labelOff={t("common:off")}
|
||||
isChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={t("webAuthnPolicyAcceptableAaguids")}
|
||||
fieldId="webAuthnPolicyAcceptableAaguids"
|
||||
labelIcon={
|
||||
<HelpItem
|
||||
helpText="authentication-help:webAuthnPolicyAcceptableAaguids"
|
||||
fieldLabelId="authentication:webAuthnPolicyAcceptableAaguids"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MultiLineInput
|
||||
name={`${namePrefix}AcceptableAaguids`}
|
||||
aria-label={t("webAuthnPolicyAcceptableAaguids")}
|
||||
addButtonLabel="authentication:addAaguids"
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormProvider>
|
||||
|
||||
<ActionGroup>
|
||||
<Button
|
||||
data-testid="save"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reload"
|
||||
variant={ButtonVariant.link}
|
||||
onClick={() => setupForm(realm)}
|
||||
>
|
||||
{t("common:reload")}
|
||||
</Button>
|
||||
</ActionGroup>
|
||||
</FormAccess>
|
||||
</PageSection>
|
||||
);
|
||||
};
|
11
src/authentication/policies/webauthn-policy.css
Normal file
11
src/authentication/policies/webauthn-policy.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.keycloak__webauthn_policies__intro {
|
||||
padding: var(--pf-global--spacer--md) 0 var(--pf-global--spacer--lg);
|
||||
color: var(--pf-global--primary-color--100);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.keycloak__webauthn_policies_authentication__form .pf-c-form__group {
|
||||
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: 10rem;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue