From e8c6a7b98d2bf3216e36d1be7c02063cb729858f Mon Sep 17 00:00:00 2001 From: Lukas Hanusovsky <61745358+lhanusov@users.noreply.github.com> Date: Mon, 15 Dec 2025 15:01:42 +0100 Subject: [PATCH] [Test Framework] Migrate initial WebAuthn setup + WebAuthnRegisterAndLoginTest. (#44016) Signed-off-by: Lukas Hanusovsky --- .github/workflows/ci.yml | 13 +- .../testframework/events/EventAssertion.java | 11 + ...nticationExecutionExportConfigBuilder.java | 55 ++ .../AuthenticationFlowConfigBuilder.java | 67 ++ .../realm/RealmConfigBuilder.java | 113 ++++ .../ui/page/LoginUsernamePage.java | 32 + .../ui/page/LogoutConfirmPage.java | 47 ++ .../testframework/ui/page/PasswordPage.java | 67 ++ .../testframework/ui/page/RegisterPage.java | 170 ++++++ .../ui/page/SelectAuthenticatorPage.java | 69 +++ .../testframework/ui/webdriver/WaitUtils.java | 2 +- .../keycloak/tests/i18n/LoginPageTest.java | 2 + tests/pom.xml | 1 + tests/webauthn/pom.xml | 106 ++++ .../DefaultVirtualAuthOptions.java | 77 +++ .../KcVirtualAuthenticator.java | 116 ++++ .../UseVirtualAuthenticators.java | 37 ++ .../VirtualAuthenticatorManager.java | 60 ++ .../webauthn/page/WebAuthnErrorPage.java | 55 ++ .../webauthn/page/WebAuthnLoginPage.java | 134 ++++ .../webauthn/page/WebAuthnRegisterPage.java | 110 ++++ .../webauthn/AbstractWebAuthnVirtualTest.java | 541 ++++++++++++++++ .../WebAuthnRegisterAndLoginTest.java | 524 ++++++++++++++++ .../test/resources/keycloak-test.properties | 17 + .../WebAuthnRegisterAndLoginTest.java | 578 ------------------ 25 files changed, 2417 insertions(+), 587 deletions(-) create mode 100644 test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationExecutionExportConfigBuilder.java create mode 100644 test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationFlowConfigBuilder.java create mode 100644 test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java create mode 100644 test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LogoutConfirmPage.java create mode 100644 test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/PasswordPage.java create mode 100644 test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/RegisterPage.java create mode 100644 test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/SelectAuthenticatorPage.java create mode 100644 tests/webauthn/pom.xml create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/DefaultVirtualAuthOptions.java create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/KcVirtualAuthenticator.java create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/UseVirtualAuthenticators.java create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/VirtualAuthenticatorManager.java create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnErrorPage.java create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnLoginPage.java create mode 100644 tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnRegisterPage.java create mode 100644 tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java create mode 100644 tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnRegisterAndLoginTest.java create mode 100644 tests/webauthn/src/test/resources/keycloak-test.properties delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4037c917cd9..fb9afd4dfa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -929,12 +929,6 @@ jobs: runs-on: ubuntu-latest needs: build timeout-minutes: 45 - strategy: - matrix: - browser: - - chrome - - firefox - fail-fast: false steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -943,13 +937,16 @@ jobs: uses: ./.github/actions/integration-test-setup - uses: ./.github/actions/install-chrome - if: matrix.browser == 'chrome' - name: Run WebAuthn IT run: | TESTS=`testsuite/integration-arquillian/tests/base/testsuites/suite.sh webauthn` echo "Tests: $TESTS" - ./mvnw test ${{ env.SUREFIRE_RETRY }} -Pauth-server-quarkus -Dtest=$TESTS -Dbrowser=${{ matrix.browser }} "-Dwebdriver.chrome.driver=$CHROMEWEBDRIVER/chromedriver" "-Dwebdriver.gecko.driver=$GECKOWEBDRIVER/geckodriver" -pl testsuite/integration-arquillian/tests/base 2>&1 | misc/log/trimmer.sh + ./mvnw test ${{ env.SUREFIRE_RETRY }} -Pauth-server-quarkus -Dtest=$TESTS -Dbrowser=chrome "-Dwebdriver.chrome.driver=$CHROMEWEBDRIVER/chromedriver" -pl testsuite/integration-arquillian/tests/base 2>&1 | misc/log/trimmer.sh + + - name: Run new WebAuthn IT + run: | + ./mvnw test -f tests/webauthn/pom.xml - uses: ./.github/actions/upload-flaky-tests name: Upload flaky tests diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java b/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java index 18a5654f89f..e62068bee26 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java @@ -1,5 +1,6 @@ package org.keycloak.testframework.events; +import org.keycloak.events.Details; import org.keycloak.events.EventType; import org.keycloak.representations.idm.EventRepresentation; @@ -37,6 +38,16 @@ public class EventAssertion { return this; } + public EventAssertion hasSessionId() { + MatcherAssert.assertThat(event.getSessionId(), EventMatchers.isSessionId()); + return this; + } + + public EventAssertion isCodeId() { + MatcherAssert.assertThat(event.getDetails().get(Details.CODE_ID), EventMatchers.isCodeId()); + return this; + } + public EventAssertion clientId(String clientId) { Assertions.assertEquals(clientId, event.getClientId()); return this; diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationExecutionExportConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationExecutionExportConfigBuilder.java new file mode 100644 index 00000000000..6230f955252 --- /dev/null +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationExecutionExportConfigBuilder.java @@ -0,0 +1,55 @@ +package org.keycloak.testframework.realm; + +import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; + +public class AuthenticationExecutionExportConfigBuilder { + + private final AuthenticationExecutionExportRepresentation rep; + + private AuthenticationExecutionExportConfigBuilder(AuthenticationExecutionExportRepresentation rep) { + this.rep = rep; + } + + public static AuthenticationExecutionExportConfigBuilder create() { + AuthenticationExecutionExportRepresentation rep = new AuthenticationExecutionExportRepresentation(); + return new AuthenticationExecutionExportConfigBuilder(rep); + } + + public static AuthenticationExecutionExportConfigBuilder update(AuthenticationExecutionExportRepresentation rep) { + return new AuthenticationExecutionExportConfigBuilder(rep); + } + + public AuthenticationExecutionExportConfigBuilder authenticator(String authenticator) { + rep.setAuthenticator(authenticator); + return this; + } + + public AuthenticationExecutionExportConfigBuilder flowAlias(String flowAlias) { + rep.setFlowAlias(flowAlias); + return this; + } + + public AuthenticationExecutionExportConfigBuilder requirement(String requirement) { + rep.setRequirement(requirement); + return this; + } + + public AuthenticationExecutionExportConfigBuilder priority(Integer priority) { + rep.setPriority(priority); + return this; + } + + public AuthenticationExecutionExportConfigBuilder userSetupAllowed(boolean allowed) { + rep.setUserSetupAllowed(allowed); + return this; + } + + public AuthenticationExecutionExportConfigBuilder authenticatorFlow(boolean enabled) { + rep.setAuthenticatorFlow(enabled); + return this; + } + + public AuthenticationExecutionExportRepresentation build() { + return rep; + } +} diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationFlowConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationFlowConfigBuilder.java new file mode 100644 index 00000000000..9a148e7d74a --- /dev/null +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/AuthenticationFlowConfigBuilder.java @@ -0,0 +1,67 @@ +package org.keycloak.testframework.realm; + +import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; + +public class AuthenticationFlowConfigBuilder { + + private final AuthenticationFlowRepresentation rep; + + private AuthenticationFlowConfigBuilder(AuthenticationFlowRepresentation rep) { + this.rep = rep; + } + + public static AuthenticationFlowConfigBuilder create() { + AuthenticationFlowRepresentation rep = new AuthenticationFlowRepresentation(); + return new AuthenticationFlowConfigBuilder(rep); + } + + public static AuthenticationFlowConfigBuilder update(AuthenticationFlowRepresentation rep) { + return new AuthenticationFlowConfigBuilder(rep); + } + + public AuthenticationFlowConfigBuilder alias(String alias) { + rep.setAlias(alias); + return this; + } + + public AuthenticationFlowConfigBuilder description(String description) { + rep.setDescription(description); + return this; + } + + public AuthenticationFlowConfigBuilder providerId(String providerId) { + rep.setProviderId(providerId); + return this; + } + + public AuthenticationFlowConfigBuilder topLevel(boolean enabled) { + rep.setTopLevel(enabled); + return this; + } + + public AuthenticationFlowConfigBuilder builtIn(boolean enabled) { + rep.setBuiltIn(enabled); + return this; + } + + public AuthenticationExecutionExportConfigBuilder addAuthenticationExecutionWithAuthenticator(String authenticator, String requirement, Integer priority, boolean userSetupAllowed) { + AuthenticationExecutionExportRepresentation exec = new AuthenticationExecutionExportRepresentation(); + rep.setAuthenticationExecutions(Collections.combine(rep.getAuthenticationExecutions(), exec)); + + return AuthenticationExecutionExportConfigBuilder.update(exec).authenticator(authenticator).requirement(requirement) + .priority(priority).userSetupAllowed(userSetupAllowed).authenticatorFlow(false); + } + + public AuthenticationExecutionExportConfigBuilder addAuthenticationExecutionWithAliasFlow(String flowAlias, String requirement, Integer priority, boolean userSetupAllowed) { + AuthenticationExecutionExportRepresentation exec = new AuthenticationExecutionExportRepresentation(); + rep.setAuthenticationExecutions(Collections.combine(rep.getAuthenticationExecutions(), exec)); + + return AuthenticationExecutionExportConfigBuilder.update(exec).flowAlias(flowAlias).requirement(requirement) + .priority(priority).userSetupAllowed(userSetupAllowed).authenticatorFlow(true); + } + + public AuthenticationFlowRepresentation build() { + return rep; + } +} diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java index 30241d8d956..f009434dce8 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.ClientPoliciesRepresentation; import org.keycloak.representations.idm.ClientPolicyRepresentation; import org.keycloak.representations.idm.ClientProfileRepresentation; @@ -13,6 +14,7 @@ import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.GroupRepresentation; import org.keycloak.representations.idm.IdentityProviderRepresentation; import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.RolesRepresentation; import org.keycloak.representations.idm.UserRepresentation; @@ -88,11 +90,22 @@ public class RealmConfigBuilder { return RoleConfigBuilder.update(role).name(roleName); } + public AuthenticationFlowConfigBuilder addAuthenticationFlow(String alias, String description, String providerId, boolean topLevel, boolean builtIn) { + AuthenticationFlowRepresentation flow = new AuthenticationFlowRepresentation(); + rep.setAuthenticationFlows(Collections.combine(rep.getAuthenticationFlows(), flow)); + return AuthenticationFlowConfigBuilder.update(flow).alias(alias).description(description).providerId(providerId).topLevel(topLevel).builtIn(builtIn); + } + public RealmConfigBuilder registrationEmailAsUsername(boolean registrationEmailAsUsername) { rep.setRegistrationEmailAsUsername(registrationEmailAsUsername); return this; } + public RealmConfigBuilder registrationAllowed(boolean allowed) { + rep.setRegistrationAllowed(allowed); + return this; + } + public RealmConfigBuilder editUsernameAllowed(boolean allowed) { rep.setEditUsernameAllowed(allowed); return this; @@ -267,6 +280,106 @@ public class RealmConfigBuilder { return this; } + public RealmConfigBuilder browserFlow(String browserFlow) { + rep.setBrowserFlow(browserFlow); + return this; + } + + public RealmConfigBuilder requiredAction(RequiredActionProviderRepresentation requiredAction) { + rep.setRequiredActions(Collections.combine(rep.getRequiredActions(), requiredAction)); + return this; + } + + public RealmConfigBuilder webAuthnPolicySignatureAlgorithms(List algorithms) { + rep.setWebAuthnPolicySignatureAlgorithms(algorithms); + return this; + } + + public RealmConfigBuilder webAuthnPolicyAttestationConveyancePreference(String preference) { + rep.setWebAuthnPolicyAttestationConveyancePreference(preference); + return this; + } + + public RealmConfigBuilder webAuthnPolicyAuthenticatorAttachment(String attachment) { + rep.setWebAuthnPolicyAuthenticatorAttachment(attachment); + return this; + } + + public RealmConfigBuilder webAuthnPolicyRequireResidentKey(String residentKey) { + rep.setWebAuthnPolicyRequireResidentKey(residentKey); + return this; + } + + public RealmConfigBuilder webAuthnPolicyUserVerificationRequirement(String requirement) { + rep.setWebAuthnPolicyUserVerificationRequirement(requirement); + return this; + } + + public RealmConfigBuilder webAuthnPolicyRpEntityName(String entityName) { + rep.setWebAuthnPolicyRpEntityName(entityName); + return this; + } + + public RealmConfigBuilder webAuthnPolicyRpId(String rpId) { + rep.setWebAuthnPolicyRpId(rpId); + return this; + } + + public RealmConfigBuilder webAuthnPolicyCreateTimeout(Integer timeout) { + rep.setWebAuthnPolicyCreateTimeout(timeout); + return this; + } + + public RealmConfigBuilder webAuthnPolicyAvoidSameAuthenticatorRegister(Boolean register) { + rep.setWebAuthnPolicyAvoidSameAuthenticatorRegister(register); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessSignatureAlgorithms(List algorithms) { + rep.setWebAuthnPolicySignatureAlgorithms(algorithms); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessAttestationConveyancePreference(String preference) { + rep.setWebAuthnPolicyPasswordlessAttestationConveyancePreference(preference); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessAuthenticatorAttachment(String attachment) { + rep.setWebAuthnPolicyPasswordlessAuthenticatorAttachment(attachment); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessRequireResidentKey(String residentKey) { + rep.setWebAuthnPolicyPasswordlessRequireResidentKey(residentKey); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessUserVerificationRequirement(String requirement) { + rep.setWebAuthnPolicyPasswordlessUserVerificationRequirement(requirement); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessRpEntityName(String entityName) { + rep.setWebAuthnPolicyPasswordlessRpEntityName(entityName); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessCreateTimeout(Integer timeout) { + rep.setWebAuthnPolicyPasswordlessCreateTimeout(timeout); + return this; + } + + public RealmConfigBuilder webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister(Boolean register) { + rep.setWebAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister(register); + return this; + } + + public RealmConfigBuilder webAuthnPolicyAcceptableAaguids(List aaguids) { + rep.setWebAuthnPolicyAcceptableAaguids(aaguids); + return this; + } + /** * Best practice is to use other convenience methods when configuring a realm, but while the framework is under * active development there may not be a way to perform all updates required. In these cases this method allows diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java new file mode 100644 index 00000000000..0dc5ba5873e --- /dev/null +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java @@ -0,0 +1,32 @@ +package org.keycloak.testframework.ui.page; + +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +public class LoginUsernamePage extends AbstractLoginPage { + + @FindBy(id = "username") + private WebElement usernameInput; + + @FindBy(css = "[type=submit]") + private WebElement submitButton; + + public LoginUsernamePage(ManagedWebDriver driver) { + super(driver); + } + + public void fillLoginWithUsernameOnly(String username) { + usernameInput.sendKeys(username); + } + + public void submit() { + submitButton.click(); + } + + @Override + public String getExpectedPageId() { + return "login-login-username"; + } +} diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LogoutConfirmPage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LogoutConfirmPage.java new file mode 100644 index 00000000000..990f3f432a6 --- /dev/null +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LogoutConfirmPage.java @@ -0,0 +1,47 @@ +/* + * Copyright 2022 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.keycloak.testframework.ui.page; + +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Marek Posolda + */ +public class LogoutConfirmPage extends AbstractLoginPage { + + @FindBy(css = "input[type=\"submit\"]") + private WebElement confirmLogoutButton; + + public LogoutConfirmPage(ManagedWebDriver driver) { + super(driver); + } + + public void confirmLogout() { + confirmLogoutButton.sendKeys(Keys.ENTER); + } + + @Override + public String getExpectedPageId() { + return "login-logout-confirm"; + } +} diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/PasswordPage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/PasswordPage.java new file mode 100644 index 00000000000..3b3b966c803 --- /dev/null +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/PasswordPage.java @@ -0,0 +1,67 @@ +package org.keycloak.testframework.ui.page; + +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * login page for PasswordForm. It contains only password, but not username + * + * @author Marek Posolda + */ +public class PasswordPage extends AbstractLoginPage { + + @FindBy(id = "password") + private WebElement passwordInput; + + @FindBy(id = "input-error-password") + private WebElement passwordError; + + @FindBy(name = "login") + private WebElement submitButton; + + @FindBy(css = "div[class^='pf-v5-c-alert'], div[class^='alert-error']") + private WebElement loginErrorMessage; + + @FindBy(linkText = "Forgot Password?") + private WebElement resetPasswordLink; + + @FindBy(id = "try-another-way") + private WebElement tryAnotherWayLink; + + public PasswordPage(ManagedWebDriver driver) { + super(driver); + } + + public void fillPassword(String password) { + passwordInput.clear(); + passwordInput.sendKeys(password); + } + + public void submit() { + submitButton.click(); + } + + public String getPassword() { + return passwordInput.getAttribute("value"); + } + + public String getError() { + try { + return loginErrorMessage.getText(); + } catch (NoSuchElementException e) { + return null; + } + } + + public void clickTryAnotherWayLink() { + tryAnotherWayLink.click(); + } + + @Override + public String getExpectedPageId() { + return "login-login-password"; + } +} diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/RegisterPage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/RegisterPage.java new file mode 100644 index 00000000000..53278c50ab3 --- /dev/null +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/RegisterPage.java @@ -0,0 +1,170 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testframework.ui.page; + +import java.util.Map; +import java.util.Map.Entry; + +import org.keycloak.models.Constants; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Stian Thorgersen + */ +public class RegisterPage extends AbstractLoginPage { + + @FindBy(name = "firstName") + private WebElement firstNameInput; + + @FindBy(name = "lastName") + private WebElement lastNameInput; + + @FindBy(name = "email") + private WebElement emailInput; + + @FindBy(name = "username") + private WebElement usernameInput; + + @FindBy(name = "password") + private WebElement passwordInput; + + @FindBy(name = "password-confirm") + private WebElement passwordConfirmInput; + + @FindBy(name = "department") + private WebElement departmentInput; + + @FindBy(name = "termsAccepted") + private WebElement termsAcceptedInput; + + @FindBy(css = "input[type=\"submit\"]") + private WebElement submitButton; + + public RegisterPage(ManagedWebDriver driver) { + super(driver); + } + + public void register(String firstName, String lastName, String email, String username, String password) { + register(firstName, lastName, email, username, password, password, null, null, null); + } + + public void register(String firstName, String lastName, String email, String username, String password, String passwordConfirm) { + register(firstName, lastName, email, username, password, passwordConfirm, null, null, null); + } + + public void register(String firstName, String lastName, String email, String username, String password, String passwordConfirm, String department, Boolean termsAccepted, Map attributes) { + firstNameInput.clear(); + if (firstName != null) { + firstNameInput.sendKeys(firstName); + } + + lastNameInput.clear(); + if (lastName != null) { + lastNameInput.sendKeys(lastName); + } + + if (email != null) { + if (isEmailPresent()) { + emailInput.clear(); + emailInput.sendKeys(email); + } + } + + usernameInput.clear(); + if (username != null) { + usernameInput.sendKeys(username); + } + + passwordInput.clear(); + if (password != null) { + passwordInput.sendKeys(password); + } + + passwordConfirmInput.clear(); + if (passwordConfirm != null) { + passwordConfirmInput.sendKeys(passwordConfirm); + } + + + if (department != null) { + if(isDepartmentPresent()) { + departmentInput.clear(); + departmentInput.sendKeys(department); + } + } + + if (termsAccepted != null && termsAccepted) { + termsAcceptedInput.click(); + } + + if (attributes != null) { + for (Entry attribute : attributes.entrySet()) { + driver.findElement(By.name(Constants.USER_ATTRIBUTES_PREFIX + attribute.getKey())).sendKeys(attribute.getValue()); + } + } + + submitButton.sendKeys(Keys.ENTER); + } + + public String getFirstName() { + return firstNameInput.getAttribute("value"); + } + + public String getLastName() { + return lastNameInput.getAttribute("value"); + } + + public String getEmail() { + return emailInput.getAttribute("value"); + } + + public String getUsername() { + return usernameInput.getAttribute("value"); + } + + public String getPassword() { + return passwordInput.getAttribute("value"); + } + + public boolean isDepartmentPresent() { + try { + return driver.findElement(By.name("department")).isDisplayed(); + } catch (NoSuchElementException nse) { + return false; + } + } + + public boolean isEmailPresent() { + try { + return driver.findElement(By.name("email")).isDisplayed(); + } catch (NoSuchElementException nse) { + return false; + } + } + + @Override + public String getExpectedPageId() { + return "login-register"; + } +} diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/SelectAuthenticatorPage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/SelectAuthenticatorPage.java new file mode 100644 index 00000000000..96942d68033 --- /dev/null +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/SelectAuthenticatorPage.java @@ -0,0 +1,69 @@ +package org.keycloak.testframework.ui.page; + +import java.util.List; + +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +/** + * Login page with the list of authentication mechanisms, which are available to the user (Password, OTP, WebAuthn...) + * + * @author Marek Posolda + * @author Peter Zaoral + */ +public class SelectAuthenticatorPage extends AbstractLoginPage { + + // Corresponds to the PasswordForm + public static final String PASSWORD = "Password"; + + // Corresponds to the WebAuthn authenticators + public static final String SECURITY_KEY = "Passkey"; + + public SelectAuthenticatorPage(ManagedWebDriver driver) { + super(driver); + } + + /** + * + * Selects the chosen login method (For example "Password") by click on it. + * + * @param loginMethodName name as displayed. For example "Password" or "Authenticator Application" + * + */ + public void selectLoginMethod(String loginMethodName) { + getLoginMethodRowByName(loginMethodName).click(); + } + + /** + * Return help text corresponding to the named login method + * + * @param loginMethodName name as displayed. For example "Password" or "Authenticator Application" + * @return + */ + public String getLoginMethodHelpText(String loginMethodName) { + return getLoginMethodRowByName(loginMethodName).findElement(By.className("select-auth-box-desc")).getText(); + } + + + private List getLoginMethodsRows() { + return driver.driver().findElements(By.className("select-auth-box-parent")); + } + + private String getLoginMethodNameFromRow(WebElement loginMethodRow) { + return loginMethodRow.findElement(By.className("select-auth-box-headline")).getText(); + } + + private WebElement getLoginMethodRowByName(String loginMethodName) { + return getLoginMethodsRows().stream() + .filter(loginMethodRow -> loginMethodName.equals(getLoginMethodNameFromRow(loginMethodRow))) + .findFirst() + .orElseThrow(() -> new AssertionError("Login method '" + loginMethodName + "' not found in the available authentication mechanisms")); + } + + @Override + public String getExpectedPageId() { + return "login-select-authenticator"; + } +} diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/webdriver/WaitUtils.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/webdriver/WaitUtils.java index c5b019eefcc..dd7481642fb 100644 --- a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/webdriver/WaitUtils.java +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/webdriver/WaitUtils.java @@ -48,7 +48,7 @@ public class WaitUtils { } private WebDriverWait createDefaultWait() { - return new WebDriverWait(managed.driver(), Duration.ofSeconds(10), Duration.ofMillis(50)); + return new WebDriverWait(managed.driver(), Duration.ofSeconds(5), Duration.ofMillis(50)); } } diff --git a/tests/base/src/test/java/org/keycloak/tests/i18n/LoginPageTest.java b/tests/base/src/test/java/org/keycloak/tests/i18n/LoginPageTest.java index 8685182b6f4..2f3eb9108ef 100644 --- a/tests/base/src/test/java/org/keycloak/tests/i18n/LoginPageTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/i18n/LoginPageTest.java @@ -431,6 +431,8 @@ public class LoginPageTest { String nonExistingUrl = oauth.loginForm().build().split("protocol")[0] + "incorrect-path"; driver.open(nonExistingUrl); + errorPage.assertCurrent(); + assertThat(driver.page().getPageSource(), containsString(realmLocalizationMessageValue)); } diff --git a/tests/pom.xml b/tests/pom.xml index a1c22ec171b..b92019870a3 100755 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -39,6 +39,7 @@ custom-providers custom-scripts clustering + webauthn diff --git a/tests/webauthn/pom.xml b/tests/webauthn/pom.xml new file mode 100644 index 00000000000..02629a2ee00 --- /dev/null +++ b/tests/webauthn/pom.xml @@ -0,0 +1,106 @@ + + + + + + keycloak-tests-parent + org.keycloak.tests + 999.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + keycloak-tests-webauthn + New Keycloak WebAuthn Testsuite + jar + New Keycloak WebAuthn Testsuite + + + + + org.keycloak.testframework + keycloak-test-framework-bom + ${project.version} + import + pom + + + + + + + org.keycloak.testframework + keycloak-test-framework-core + compile + + + org.keycloak.testframework + keycloak-test-framework-junit5-config + + + org.keycloak.testframework + keycloak-test-framework-ui + compile + + + org.keycloak.testframework + keycloak-test-framework-oauth + + + org.keycloak.testframework + keycloak-test-framework-remote + + + org.keycloak.tests + keycloak-tests-utils + ${project.version} + test + + + org.keycloak.tests + keycloak-tests-utils-shared + ${project.version} + test + + + org.junit.platform + junit-platform-suite + test + + + org.jboss.logmanager + jboss-logmanager + test + + + + + + + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory + + + + + + diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/DefaultVirtualAuthOptions.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/DefaultVirtualAuthOptions.java new file mode 100644 index 00000000000..2ad9820f97d --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/DefaultVirtualAuthOptions.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn.authenticators; + +import java.util.function.Supplier; + +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; + +import static org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions.Protocol.U2F; +import static org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions.Transport.BLE; +import static org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions.Transport.INTERNAL; +import static org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions.Transport.NFC; +import static org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions.Transport.USB; + +/** + * Default Options for various authenticators + * + * @author Martin Bartos + */ +public enum DefaultVirtualAuthOptions { + DEFAULT(VirtualAuthenticatorOptions::new), + + DEFAULT_BLE(() -> DEFAULT.getOptions().setTransport(BLE)), + DEFAULT_NFC(() -> DEFAULT.getOptions().setTransport(NFC)), + DEFAULT_USB(() -> DEFAULT.getOptions().setTransport(USB)), + DEFAULT_INTERNAL(() -> DEFAULT.getOptions().setTransport(INTERNAL)), + DEFAULT_RESIDENT_KEY(() -> DEFAULT.getOptions() + .setHasResidentKey(true) + .setHasUserVerification(true) + .setIsUserVerified(true) + .setIsUserConsenting(true)), + PASSKEYS(() -> DEFAULT_RESIDENT_KEY.getOptions().setTransport(INTERNAL)), + + YUBIKEY_4(DefaultVirtualAuthOptions::getYubiKeyGeneralOptions), + YUBIKEY_5_USB(DefaultVirtualAuthOptions::getYubiKeyGeneralOptions), + YUBIKEY_5_NFC(() -> getYubiKeyGeneralOptions().setTransport(NFC)), + + TOUCH_ID(() -> DEFAULT.getOptions() + .setTransport(INTERNAL) + .setHasUserVerification(true) + .setIsUserVerified(true) + ); + + private final Supplier options; + + DefaultVirtualAuthOptions(Supplier options) { + this.options = options; + } + + public final VirtualAuthenticatorOptions getOptions() { + return options.get(); + } + + private static VirtualAuthenticatorOptions getYubiKeyGeneralOptions() { + return new VirtualAuthenticatorOptions() + .setTransport(USB) + .setProtocol(U2F) + .setHasUserVerification(true) + .setIsUserConsenting(true) + .setIsUserVerified(true); + } +} diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/KcVirtualAuthenticator.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/KcVirtualAuthenticator.java new file mode 100644 index 00000000000..4f4eea2b1c8 --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/KcVirtualAuthenticator.java @@ -0,0 +1,116 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn.authenticators; + +import java.util.Arrays; +import java.util.Map; + +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticator; +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; + +/** + * Keycloak Virtual Authenticator + *

+ * Used as wrapper for VirtualAuthenticator and its options* + * + * @author Martin Bartos + */ +public class KcVirtualAuthenticator { + private final VirtualAuthenticator authenticator; + private final Options options; + + public KcVirtualAuthenticator(VirtualAuthenticator authenticator, VirtualAuthenticatorOptions options) { + this.authenticator = authenticator; + this.options = new Options(options); + } + + public VirtualAuthenticator getAuthenticator() { + return authenticator; + } + + public Options getOptions() { + return options; + } + + public static final class Options { + private final VirtualAuthenticatorOptions options; + private final VirtualAuthenticatorOptions.Protocol protocol; + private final VirtualAuthenticatorOptions.Transport transport; + private final boolean hasResidentKey; + private final boolean hasUserVerification; + private final boolean isUserConsenting; + private final boolean isUserVerified; + private final Map map; + + private Options(VirtualAuthenticatorOptions options) { + this.options = options; + + this.map = options.toMap(); + this.protocol = protocolFromMap(map); + this.transport = transportFromMap(map); + this.hasResidentKey = (Boolean) map.get("hasResidentKey"); + this.hasUserVerification = (Boolean) map.get("hasUserVerification"); + this.isUserConsenting = (Boolean) map.get("isUserConsenting"); + this.isUserVerified = (Boolean) map.get("isUserVerified"); + } + + public VirtualAuthenticatorOptions.Protocol getProtocol() { + return protocol; + } + + public VirtualAuthenticatorOptions.Transport getTransport() { + return transport; + } + + public boolean hasResidentKey() { + return hasResidentKey; + } + + public boolean hasUserVerification() { + return hasUserVerification; + } + + public boolean isUserConsenting() { + return isUserConsenting; + } + + public boolean isUserVerified() { + return isUserVerified; + } + + public VirtualAuthenticatorOptions clone() { + return options; + } + + public Map asMap() { + return map; + } + + private static VirtualAuthenticatorOptions.Protocol protocolFromMap(Map map) { + return Arrays.stream(VirtualAuthenticatorOptions.Protocol.values()) + .filter(f -> f.id.equals(map.get("protocol"))) + .findFirst().orElse(null); + } + + private static VirtualAuthenticatorOptions.Transport transportFromMap(Map map) { + return Arrays.stream(VirtualAuthenticatorOptions.Transport.values()) + .filter(f -> f.id.equals(map.get("transport"))) + .findFirst().orElse(null); + } + } +} diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/UseVirtualAuthenticators.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/UseVirtualAuthenticators.java new file mode 100644 index 00000000000..661f3de3198 --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/UseVirtualAuthenticators.java @@ -0,0 +1,37 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn.authenticators; + + +/** + * Interface for test classes which use Virtual Authenticators + * + * @author Martin Bartos + */ +public interface UseVirtualAuthenticators { + + /** + * Set up Virtual Authenticator in @Before method for each test method + */ + void setUpVirtualAuthenticator(); + + /** + * Remove Virtual Authenticator in @After method for each test method + */ + void removeVirtualAuthenticator(); +} diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/VirtualAuthenticatorManager.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/VirtualAuthenticatorManager.java new file mode 100644 index 00000000000..d541f3e7d6f --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/authenticators/VirtualAuthenticatorManager.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn.authenticators; + +import org.hamcrest.CoreMatchers; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.virtualauthenticator.HasVirtualAuthenticator; +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; + +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Manager for Virtual Authenticators + * + * @author Martin Bartos + */ +public class VirtualAuthenticatorManager { + private final HasVirtualAuthenticator driver; + private KcVirtualAuthenticator currentAuthenticator; + + public VirtualAuthenticatorManager(WebDriver driver) { + assertThat("Driver must support Virtual Authenticators", driver, CoreMatchers.instanceOf(HasVirtualAuthenticator.class)); + this.driver = (HasVirtualAuthenticator) driver; + } + + public KcVirtualAuthenticator useAuthenticator(VirtualAuthenticatorOptions options) { + if (options == null) return null; + + removeAuthenticator(); + this.currentAuthenticator = new KcVirtualAuthenticator(driver.addVirtualAuthenticator(options), options); + return currentAuthenticator; + } + + public KcVirtualAuthenticator getCurrent() { + return currentAuthenticator; + } + + public void removeAuthenticator() { + if (currentAuthenticator != null) { + currentAuthenticator.getAuthenticator().removeAllCredentials(); + driver.removeVirtualAuthenticator(currentAuthenticator.getAuthenticator()); + this.currentAuthenticator = null; + } + } +} diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnErrorPage.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnErrorPage.java new file mode 100644 index 00000000000..196682e5607 --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnErrorPage.java @@ -0,0 +1,55 @@ +package org.keycloak.tests.webauthn.page; + +import org.keycloak.testframework.ui.page.AbstractLoginPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.junit.jupiter.api.Assertions; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + + +/** + * @author Martin Bartos + */ +public class WebAuthnErrorPage extends AbstractLoginPage { + + @FindBy(id = "kc-try-again") + private WebElement tryAgainButton; + + // Available only with AIA + @FindBy(id = "cancelWebAuthnAIA") + private WebElement cancelRegistrationAIA; + + @FindBy(css = "div[class^='pf-v5-c-alert'], div[class^='alert-error']") + private WebElement errorMessage; + + public WebAuthnErrorPage(ManagedWebDriver driver) { + super(driver); + } + + public void clickTryAgain() { + tryAgainButton.click(); + } + + public void clickCancelRegistrationAIA() { + try { + cancelRegistrationAIA.click(); + } catch (NoSuchElementException e) { + Assertions.fail("It only works with AIA"); + } + } + + public String getError() { + try { + return errorMessage.getText(); + } catch (NoSuchElementException e) { + return null; + } + } + + @Override + public String getExpectedPageId() { + return "login-webauthn-error"; + } +} diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnLoginPage.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnLoginPage.java new file mode 100644 index 00000000000..fdaf1b65660 --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnLoginPage.java @@ -0,0 +1,134 @@ +/* + * Copyright 2019 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn.page; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.keycloak.testframework.ui.page.AbstractLoginPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * Page shown during WebAuthn login. Page is useful with Chrome testing API + */ +public class WebAuthnLoginPage extends AbstractLoginPage { + + @FindBy(id = "authenticateWebAuthnButton") + private WebElement authenticateButton; + + @FindBy(xpath = "//div[contains(@id,'kc-webauthn-authenticator-label-')]") + private List authenticatorsLabels; + + @FindBy(xpath = "//div[contains(@id,'kc-webauthn-authenticator-item-')]") + private List authenticators; + + public WebAuthnLoginPage(ManagedWebDriver driver) { + super(driver); + } + + public void clickAuthenticate() { + authenticateButton.click(); + } + + public List getItems() { + try { + List items = new ArrayList<>(); + for (int i = 0; i < authenticators.size(); i++) { + WebElement auth = authenticators.get(i); + final String nameId = "kc-webauthn-authenticator-label-" + i; + String name = auth.findElement(By.id(nameId)).isDisplayed() ? + auth.findElement(By.id(nameId)).getText() : null; + final String createdAtId = "kc-webauthn-authenticator-created-" + i; + String createdAt = auth.findElement(By.id(createdAtId)).isDisplayed() ? + auth.findElement(By.id(createdAtId)).getText() : null; + final String createdAtLabelId = "kc-webauthn-authenticator-createdlabel-" + i; + String createdAtLabel = auth.findElement(By.id(createdAtLabelId)).isDisplayed() ? + auth.findElement(By.id(createdAtLabelId)).getText() : null; + final String transportId = "kc-webauthn-authenticator-transport-" + i; + String transport = auth.findElement(By.id(transportId)).isDisplayed() ? + auth.findElement(By.id(transportId)).getText() : null; + items.add(new WebAuthnAuthenticatorItem(name, createdAt, createdAtLabel, transport)); + } + return items; + } catch (NoSuchElementException e) { + return Collections.emptyList(); + } + } + + public int getCount() { + try { + return authenticators.size(); + } catch (NoSuchElementException e) { + return 0; + } + } + + public List getLabels() { + try { + return getItems().stream() + .filter(Objects::nonNull) + .map(WebAuthnAuthenticatorItem::getName) + .collect(Collectors.toList()); + } catch (NoSuchElementException e) { + return Collections.emptyList(); + } + } + + @Override + public String getExpectedPageId() { + return "login-webauthn-authenticate"; + } + + public static class WebAuthnAuthenticatorItem { + private final String name; + private final String createdAt; + private final String createdAtLabel; + private final String transport; + + public WebAuthnAuthenticatorItem(String name, String createdAt, String createdAtLabel, String transport) { + this.name = name; + this.createdAt = createdAt; + this.createdAtLabel = createdAtLabel; + this.transport = transport; + } + + public String getName() { + return name; + } + + public String getCreatedDate() { + return createdAt; + } + + public String getCreatedLabel() { + return createdAtLabel; + } + + public String getTransport() { + return transport; + } + } +} diff --git a/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnRegisterPage.java b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnRegisterPage.java new file mode 100644 index 00000000000..4e7a6097984 --- /dev/null +++ b/tests/webauthn/src/main/java/org/keycloak/tests/webauthn/page/WebAuthnRegisterPage.java @@ -0,0 +1,110 @@ +/* + * Copyright 2019 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn.page; + +import java.time.Duration; + +import org.keycloak.testframework.ui.page.AbstractLoginPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; + +import org.hamcrest.CoreMatchers; +import org.openqa.selenium.Alert; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * WebAuthnRegisterPage, which is displayed when WebAuthnRegister required action is triggered. It is useful with Chrome testing API. + *

+ * Page will be displayed after successful JS call of "navigator.credentials.create", which will register WebAuthn credential + * with the browser + */ +public class WebAuthnRegisterPage extends AbstractLoginPage { + + public static final long ALERT_CHECK_TIMEOUT = 3; //seconds + public static final long ALERT_DEFAULT_TIMEOUT = 60; //seconds + + @FindBy(id = "registerWebAuthn") + private WebElement registerButton; + + // Available only with AIA + @FindBy(id = "cancelWebAuthnAIA") + private WebElement cancelAIAButton; + + @FindBy(id = "kc-page-title") + private WebElement formTitle; + + public WebAuthnRegisterPage(ManagedWebDriver driver) { + super(driver); + } + + public void clickRegister() { + registerButton.click(); + } + + public void cancelAIA() { + assertThat("It only works with AIA", isAIA(), CoreMatchers.is(true)); + cancelAIAButton.click(); + } + + public void registerWebAuthnCredential(String authenticatorLabel) { + if (!isRegisterAlertPresent(ALERT_DEFAULT_TIMEOUT)) { + throw new TimeoutException("Cannot register Passkey due to missing prompt for registration"); + } + + Alert promptDialog = driver.driver().switchTo().alert(); + promptDialog.sendKeys(authenticatorLabel); + promptDialog.accept(); + } + + public boolean isRegisterAlertPresent() { + return isRegisterAlertPresent(ALERT_CHECK_TIMEOUT); + } + + public boolean isRegisterAlertPresent(long seconds) { + try { + // label edit after registering authenticator by .create() + WebDriverWait wait = new WebDriverWait(driver.driver(), Duration.ofSeconds(seconds)); + Alert promptDialog = wait.until(ExpectedConditions.alertIsPresent()); + assertThat(promptDialog.getText(), CoreMatchers.is("Please input your registered passkey's label")); + return true; + } catch (TimeoutException e) { + return false; + } + } + + + public boolean isAIA() { + try { + cancelAIAButton.getText(); + return true; + } catch (NoSuchElementException e) { + return false; + } + } + + @Override + public String getExpectedPageId() { + return "login-webauthn-register"; + } +} diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java new file mode 100644 index 00000000000..144ae146347 --- /dev/null +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java @@ -0,0 +1,541 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.tests.webauthn; + +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; +import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; +import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; +import org.keycloak.common.util.SecretGenerator; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; +import org.keycloak.representations.idm.AuthenticationFlowRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.RequiredActionProviderRepresentation; +import org.keycloak.testframework.annotations.InjectEvents; +import org.keycloak.testframework.annotations.InjectRealm; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.events.Events; +import org.keycloak.testframework.oauth.OAuthClient; +import org.keycloak.testframework.oauth.TestApp; +import org.keycloak.testframework.oauth.annotations.InjectOAuthClient; +import org.keycloak.testframework.oauth.annotations.InjectTestApp; +import org.keycloak.testframework.realm.AuthenticationFlowConfigBuilder; +import org.keycloak.testframework.realm.ManagedRealm; +import org.keycloak.testframework.realm.RealmConfig; +import org.keycloak.testframework.realm.RealmConfigBuilder; +import org.keycloak.testframework.ui.annotations.InjectPage; +import org.keycloak.testframework.ui.annotations.InjectWebDriver; +import org.keycloak.testframework.ui.page.ErrorPage; +import org.keycloak.testframework.ui.page.InfoPage; +import org.keycloak.testframework.ui.page.LoginPage; +import org.keycloak.testframework.ui.page.LoginUsernamePage; +import org.keycloak.testframework.ui.page.LogoutConfirmPage; +import org.keycloak.testframework.ui.page.RegisterPage; +import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; +import org.keycloak.tests.utils.admin.AdminApiUtil; +import org.keycloak.tests.webauthn.authenticators.DefaultVirtualAuthOptions; +import org.keycloak.tests.webauthn.authenticators.KcVirtualAuthenticator; +import org.keycloak.tests.webauthn.authenticators.UseVirtualAuthenticators; +import org.keycloak.tests.webauthn.authenticators.VirtualAuthenticatorManager; +import org.keycloak.tests.webauthn.page.WebAuthnErrorPage; +import org.keycloak.tests.webauthn.page.WebAuthnLoginPage; +import org.keycloak.tests.webauthn.page.WebAuthnRegisterPage; + +import org.jboss.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.firefox.FirefoxDriver; +import org.openqa.selenium.virtualauthenticator.Credential; +import org.openqa.selenium.virtualauthenticator.VirtualAuthenticatorOptions; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Abstract class for WebAuthn tests which use Virtual Authenticators + * + * @author Martin Bartos + */ +@KeycloakIntegrationTest +public abstract class AbstractWebAuthnVirtualTest implements UseVirtualAuthenticators { + + @InjectRealm(ref = "webauthn", config = WebAuthnRealmConfig.class) + ManagedRealm managedRealm; + + @InjectEvents(realmRef = "webauthn") + Events events; + + @InjectOAuthClient(realmRef = "webauthn") + OAuthClient oAuthClient; + + @InjectTestApp + TestApp testApp; + + @InjectWebDriver + ManagedWebDriver driver; + + @InjectPage + protected LoginPage loginPage; + + @InjectPage + protected LoginUsernamePage loginUsernamePage; + + @InjectPage + protected ErrorPage errorPage; + + @InjectPage + protected RegisterPage registerPage; + + @InjectPage + protected WebAuthnRegisterPage webAuthnRegisterPage; + + @InjectPage + protected WebAuthnErrorPage webAuthnErrorPage; + + @InjectPage + protected WebAuthnLoginPage webAuthnLoginPage; + + @InjectPage + protected LogoutConfirmPage logoutConfirmPage; + + @InjectPage + protected InfoPage infoPage; + + protected static final Logger LOGGER = Logger.getLogger(AbstractWebAuthnVirtualTest.class); + protected static final String ALL_ZERO_AAGUID = "00000000-0000-0000-0000-000000000000"; + protected static final String ALL_ONE_AAGUID = "11111111-1111-1111-1111-111111111111"; + protected static final String USERNAME = "UserWebAuthn"; + protected static final String PASSWORD = generatePassword(); + protected static final String EMAIL = "UserWebAuthn@email"; + + protected final static String base64EncodedPK = + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg8_zMDQDYAxlU-Q" + + "hk1Dwkf0v18GZca1DMF3SaJ9HPdmShRANCAASNYX5lyVCOZLzFZzrIKmeZ2jwU" + + "RmgsJYxGP__fWN_S-j5sN4tT15XEpN_7QZnt14YvI6uvAgO0uJEboFaZlOEB"; + + protected final static PKCS8EncodedKeySpec privateKey = new PKCS8EncodedKeySpec(Base64.getUrlDecoder().decode(base64EncodedPK)); + + private VirtualAuthenticatorManager virtualAuthenticatorManager; + + @BeforeEach + public void initWebAuthnTestRealm() { + RealmRepresentation realmRep = managedRealm.admin().toRepresentation(); + if (isPasswordless()) { + makePasswordlessRequiredActionDefault(realmRep); + switchExecutionInBrowserFormToPasswordless(realmRep); + } + managedRealm.updateWithCleanup(r -> r.update(realmRep)); + + setUpVirtualAuthenticator(); + } + + @AfterEach + public void cleanup() { + removeVirtualAuthenticator(); + } + + @Override + public void setUpVirtualAuthenticator() { + this.virtualAuthenticatorManager = createDefaultVirtualManager(driver.driver(), getDefaultAuthenticatorOptions()); + events.clear(); + } + + @Override + public void removeVirtualAuthenticator() { + virtualAuthenticatorManager.removeAuthenticator(); + events.clear(); + } + + public UserResource userResource() { + return AdminApiUtil.findUserByUsernameId(managedRealm.admin(), USERNAME); + } + + public VirtualAuthenticatorOptions getDefaultAuthenticatorOptions() { + return DefaultVirtualAuthOptions.DEFAULT.getOptions(); + } + + public VirtualAuthenticatorManager getVirtualAuthManager() { + return virtualAuthenticatorManager; + } + + public void setVirtualAuthManager(VirtualAuthenticatorManager manager) { + this.virtualAuthenticatorManager = manager; + } + + public String getCredentialType() { + return isPasswordless() ? WebAuthnCredentialModel.TYPE_PASSWORDLESS : WebAuthnCredentialModel.TYPE_TWOFACTOR; + } + + public boolean isPasswordless() { + return false; + } + + public static VirtualAuthenticatorManager createDefaultVirtualManager(WebDriver webDriver, VirtualAuthenticatorOptions options) { + VirtualAuthenticatorManager manager = new VirtualAuthenticatorManager(webDriver); + manager.useAuthenticator(options); + return manager; + } + + // Registration + + protected void registerDefaultUser() { + registerDefaultUser(true); + } + + protected void registerDefaultUser(boolean shouldSuccess) { + registerDefaultUser(SecretGenerator.getInstance().randomString(24), shouldSuccess); + } + + protected void registerDefaultUser(String authenticatorLabel) { + registerDefaultUser(authenticatorLabel, true); + } + + private void registerDefaultUser(String authenticatorLabel, boolean shouldSuccess) { + registerUser(USERNAME, PASSWORD, EMAIL, authenticatorLabel, shouldSuccess); + } + + protected void registerUser(String username, String password, String email, String authenticatorLabel, boolean shouldSuccess) { + oAuthClient.openRegistrationForm(); + + registerPage.assertCurrent(); + registerPage.register("firstName", "lastName", email, username, password, password); + + // User was registered. Now he needs to register WebAuthn credential + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + + if (shouldSuccess) { + events.clear(); + tryRegisterAuthenticator(authenticatorLabel); + } + } + + private void tryRegisterAuthenticator(String authenticatorLabel) { + tryRegisterAuthenticator(authenticatorLabel, 10); + } + + /** + * Helper method for registering Passkey + * Sometimes, it's not possible to register the key, when the Resident Key is required + * It seems it's related to Virtual authenticators provided by Selenium framework + * Manual testing with Google Chrome authenticators works as expected + */ + private void tryRegisterAuthenticator(String authenticatorLabel, int numberOfAllowedRetries) { + final boolean hasResidentKey = Optional.ofNullable(getVirtualAuthManager()) + .map(VirtualAuthenticatorManager::getCurrent) + .map(KcVirtualAuthenticator::getOptions) + .map(KcVirtualAuthenticator.Options::hasResidentKey) + .orElse(false); + + if (hasResidentKey && !webAuthnRegisterPage.isRegisterAlertPresent()) { + for (int i = 0; i < numberOfAllowedRetries; i++) { + events.clear(); + webAuthnErrorPage.clickTryAgain(); + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + + if (webAuthnRegisterPage.isRegisterAlertPresent()) { + webAuthnRegisterPage.registerWebAuthnCredential(authenticatorLabel); + return; + } else { + webAuthnRegisterPage.assertCurrent(); + } + } + } else { + webAuthnRegisterPage.registerWebAuthnCredential(authenticatorLabel); + } + } + + // Authentication + + protected void authenticateDefaultUser() { + authenticateDefaultUser(true); + } + + protected void authenticateDefaultUser(boolean shouldSuccess) { + authenticateUser("test-user@localhost", "password", shouldSuccess); + } + + protected void authenticateUser(String username, String password, boolean shouldSuccess) { + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + loginPage.fillLogin(username, password); + loginPage.submit(); + + webAuthnLoginPage.assertCurrent(); + webAuthnLoginPage.clickAuthenticate(); + + if (shouldSuccess) { + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + } else { + displayErrorMessageIfPresent(); + } + } + + protected String displayErrorMessageIfPresent() { + if (webAuthnErrorPage.getExpectedPageId().equals(driver.page().getCurrentPageId())) { + final String msg = webAuthnErrorPage.getError(); + LOGGER.info("Error message from Error Page: " + msg); + return msg; + } + return null; + } + + protected Credential getDefaultResidentKeyCredential() { + byte[] credentialId = {1, 2, 3, 4}; + byte[] userHandle = {1}; + return Credential.createResidentCredential(credentialId, "localhost", privateKey, userHandle, 0); + } + + protected Credential getDefaultNonResidentKeyCredential() { + byte[] credentialId = {1, 2, 3, 4}; + return Credential.createNonResidentCredential(credentialId, "localhost", privateKey, 0); + } + + protected static void makePasswordlessRequiredActionDefault(RealmRepresentation realm) { + RequiredActionProviderRepresentation webAuthnProvider = realm.getRequiredActions() + .stream() + .filter(f -> f.getProviderId().equals(WebAuthnRegisterFactory.PROVIDER_ID)) + .findFirst() + .orElse(null); + assertThat(webAuthnProvider, notNullValue()); + + webAuthnProvider.setEnabled(false); + + RequiredActionProviderRepresentation webAuthnPasswordlessProvider = realm.getRequiredActions() + .stream() + .filter(f -> f.getProviderId().equals(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)) + .findFirst() + .orElse(null); + assertThat(webAuthnPasswordlessProvider, notNullValue()); + + webAuthnPasswordlessProvider.setEnabled(true); + webAuthnPasswordlessProvider.setDefaultAction(true); + } + + /** + * Changes the flow "browser-webauthn-forms" to use the passed authenticator as required. + * @param realm The realm representation + * @param providerId The provider Id to set as required + */ + protected void switchExecutionInBrowserFormToProvider(RealmRepresentation realm, String providerId) { + List flows = realm.getAuthenticationFlows(); + assertThat(flows, notNullValue()); + + AuthenticationFlowRepresentation browserForm = flows.stream() + .filter(f -> f.getAlias().equals("browser-webauthn-forms")) + .findFirst() + .orElse(null); + assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, notNullValue()); + + flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias())); + + // set just one authenticator with the passkeys conditional UI + AuthenticationExecutionExportRepresentation passkeysConditionalUI = new AuthenticationExecutionExportRepresentation(); + passkeysConditionalUI.setAuthenticator(providerId); + passkeysConditionalUI.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); + passkeysConditionalUI.setPriority(10); + passkeysConditionalUI.setAuthenticatorFlow(false); + passkeysConditionalUI.setUserSetupAllowed(false); + + browserForm.setAuthenticationExecutions(List.of(passkeysConditionalUI)); + flows.add(browserForm); + + realm.setAuthenticationFlows(flows); + } + + // Switch WebAuthn authenticator with Passwordless authenticator in browser flow + protected void switchExecutionInBrowserFormToPasswordless(RealmRepresentation realm) { + List flows = realm.getAuthenticationFlows(); + assertThat(flows, notNullValue()); + + AuthenticationFlowRepresentation browserForm = flows.stream() + .filter(f -> f.getAlias().equals("browser-webauthn-forms")) + .findFirst() + .orElse(null); + assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, notNullValue()); + + flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias())); + + List browserFormExecutions = browserForm.getAuthenticationExecutions(); + assertThat("Flow 'browser-webauthn-forms' doesn't have any executions", browserForm, notNullValue()); + + AuthenticationExecutionExportRepresentation webAuthn = browserFormExecutions.stream() + .filter(f -> WebAuthnAuthenticatorFactory.PROVIDER_ID.equals(f.getAuthenticator())) + .findFirst() + .orElse(null); + assertThat("Cannot find WebAuthn execution in Browser flow", webAuthn, notNullValue()); + + browserFormExecutions.removeIf(f -> webAuthn.getAuthenticator().equals(f.getAuthenticator())); + webAuthn.setAuthenticator(WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID); + browserFormExecutions.add(webAuthn); + browserForm.setAuthenticationExecutions(browserFormExecutions); + flows.add(browserForm); + + realm.setAuthenticationFlows(flows); + } + + protected void logout() { + try { + oAuthClient.openLogoutForm(); + logoutConfirmPage.assertCurrent(); + logoutConfirmPage.confirmLogout(); + infoPage.assertCurrent(); + Assertions.assertEquals("You are logged out", infoPage.getInfo()); + } catch (Exception e) { + throw new RuntimeException("Cannot logout user", e); + } + } + + protected String getExpectedMessageByDriver(Map, String> values) { + if (values == null || values.isEmpty()) return ""; + + return values.entrySet() + .stream() + .filter(Objects::nonNull) + .filter(f -> f.getKey().isAssignableFrom(driver.getClass())) + .findFirst() + .map(Map.Entry::getValue) + .orElse(""); + } + + protected String getExpectedMessageByDriver(String firefoxMessage, String chromeMessage) { + final Map, String> map = new HashMap<>(); + map.put(FirefoxDriver.class, firefoxMessage); + map.put(ChromeDriver.class, chromeMessage); + + return getExpectedMessageByDriver(map); + } + + protected void checkWebAuthnConfiguration(String residentKey, String userVerification) { + RealmRepresentation realmRep = managedRealm.admin().toRepresentation(); + assertThat(realmRep, notNullValue()); + if(!isPasswordless()) { + assertThat(realmRep.getWebAuthnPolicyRpEntityName(), is("localhost")); + assertThat(realmRep.getWebAuthnPolicyRequireResidentKey(), is(residentKey)); + assertThat(realmRep.getWebAuthnPolicyUserVerificationRequirement(), is(userVerification)); + } else { + assertThat(realmRep.getWebAuthnPolicyPasswordlessRpEntityName(), is("localhost")); + assertThat(realmRep.getWebAuthnPolicyPasswordlessRequireResidentKey(), is(residentKey)); + assertThat(realmRep.getWebAuthnPolicyPasswordlessUserVerificationRequirement(), is(userVerification)); + } + } + + protected static String generatePassword() { + return SecretGenerator.getInstance().randomString(64); + } + + public static class WebAuthnRealmConfig implements RealmConfig { + + @Override + public RealmConfigBuilder configure(RealmConfigBuilder builder) { + builder.name("webauthn").registrationAllowed(true); + + AuthenticationFlowConfigBuilder flowBuilder1 = builder + .addAuthenticationFlow("browser-webauthn", "browser based authentication", "basic-flow", true, false); + flowBuilder1.addAuthenticationExecutionWithAuthenticator("auth-cookie", "ALTERNATIVE", 10, false); + flowBuilder1.addAuthenticationExecutionWithAuthenticator("auth-spnego", "DISABLED", 20, false); + flowBuilder1.addAuthenticationExecutionWithAuthenticator("identity-provider-redirector", "DISABLED", 25, false); + flowBuilder1.addAuthenticationExecutionWithAliasFlow("browser-webauthn-organization", "ALTERNATIVE", 26, false); + flowBuilder1.addAuthenticationExecutionWithAliasFlow("browser-webauthn-forms","ALTERNATIVE", 30, false); + + builder.addAuthenticationFlow("browser-webauthn-organization", "", "basic-flow", false, true) + .addAuthenticationExecutionWithAliasFlow("browser-webauthn-conditional-organization", "CONDITIONAL", 10, false); + + AuthenticationFlowConfigBuilder flowBuilder2 = builder.addAuthenticationFlow("browser-webauthn-conditional-organization", "Flow to determine if the organization identity-first login is to be used", "basic-flow", false, true); + flowBuilder2.addAuthenticationExecutionWithAuthenticator("conditional-user-configured", "REQUIRED", 10, false); + flowBuilder2.addAuthenticationExecutionWithAuthenticator("organization", "ALTERNATIVE" , 20, false); + + AuthenticationFlowConfigBuilder flowBuilder3 = builder.addAuthenticationFlow("browser-webauthn-forms", "Username, password, otp and other auth forms.", "basic-flow", false,false); + flowBuilder3.addAuthenticationExecutionWithAuthenticator("auth-username-password-form", "REQUIRED", 10, false); + flowBuilder3.addAuthenticationExecutionWithAuthenticator("auth-otp-form", "DISABLED" , 20, false); + flowBuilder3.addAuthenticationExecutionWithAuthenticator("webauthn-authenticator", "REQUIRED", 21, false); + + AuthenticationFlowConfigBuilder flowBuilder4 = builder.addAuthenticationFlow("browser-webauthn-passwordless", "browser based authentication", "basic-flow", true, false); + flowBuilder4.addAuthenticationExecutionWithAuthenticator("auth-cookie", "ALTERNATIVE", 10, false); + flowBuilder4.addAuthenticationExecutionWithAliasFlow("browser-webauthn-passwordless-forms", "ALTERNATIVE", 30, false); + + AuthenticationFlowConfigBuilder flowBuilder5 = builder.addAuthenticationFlow("browser-webauthn-passwordless-forms", "Username, password, otp and other auth forms.", "basic-flow", false, false); + flowBuilder5.addAuthenticationExecutionWithAuthenticator("auth-username-password-form", "REQUIRED", 10, false); + flowBuilder5.addAuthenticationExecutionWithAuthenticator("webauthn-authenticator", "REQUIRED", 20, false); + flowBuilder5.addAuthenticationExecutionWithAuthenticator("webauthn-authenticator-passwordless", "REQUIRED", 30, false); + + RequiredActionProviderRepresentation actionRep1 = new RequiredActionProviderRepresentation(); + actionRep1.setAlias("webauthn-register"); + actionRep1.setName("Webauthn Register"); + actionRep1.setProviderId("webauthn-register"); + actionRep1.setEnabled(true); + actionRep1.setDefaultAction(true); + actionRep1.setPriority(51); + actionRep1.setConfig(Collections.emptyMap()); + + builder.requiredAction(actionRep1); + + RequiredActionProviderRepresentation actionRep2 = new RequiredActionProviderRepresentation(); + actionRep2.setAlias("webauthn-register-passwordless"); + actionRep2.setName("Webauthn Register Passwordless"); + actionRep2.setProviderId("webauthn-register-passwordless"); + actionRep2.setEnabled(true); + actionRep2.setDefaultAction(false); + actionRep2.setPriority(52); + actionRep2.setConfig(Collections.emptyMap()); + + builder.requiredAction(actionRep2); + + builder.webAuthnPolicySignatureAlgorithms(List.of("ES256", "RS256", "RS1")) + .webAuthnPolicyAttestationConveyancePreference("not specified") + .webAuthnPolicyAuthenticatorAttachment("not specified") + .webAuthnPolicyRequireResidentKey("not specified") + .webAuthnPolicyUserVerificationRequirement("not specified") + .webAuthnPolicyRpEntityName("keycloak-webauthn-2FA") + .webAuthnPolicyCreateTimeout(60) + .webAuthnPolicyAvoidSameAuthenticatorRegister(true); + + builder.webAuthnPolicyPasswordlessSignatureAlgorithms(List.of("ES256", "RS256", "RS1")) + .webAuthnPolicyPasswordlessAttestationConveyancePreference("not specified") + .webAuthnPolicyPasswordlessAuthenticatorAttachment("not specified") + .webAuthnPolicyPasswordlessRequireResidentKey("not specified") + .webAuthnPolicyPasswordlessUserVerificationRequirement("not specified") + .webAuthnPolicyPasswordlessRpEntityName("keycloak-webauthn-passwordless-2FA") + .webAuthnPolicyPasswordlessCreateTimeout(60) + .webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister(true); + + builder.browserFlow("browser-webauthn"); + + builder.addUser(USERNAME).password(PASSWORD).name("WebAuthn", "User") + .email("webauthn-user@localhost").emailVerified(true); + return builder; + } + } +} diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnRegisterAndLoginTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnRegisterAndLoginTest.java new file mode 100644 index 00000000000..54976468101 --- /dev/null +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/WebAuthnRegisterAndLoginTest.java @@ -0,0 +1,524 @@ +/* + * Copyright 2019 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.tests.webauthn; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import jakarta.ws.rs.core.Response; + +import org.keycloak.WebAuthnConstants; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; +import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; +import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; +import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; +import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; +import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; +import org.keycloak.common.util.SecretGenerator; +import org.keycloak.events.Details; +import org.keycloak.events.EventType; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.models.credential.dto.WebAuthnCredentialData; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testframework.annotations.KeycloakIntegrationTest; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; +import org.keycloak.testframework.remote.runonserver.RunOnServerClient; +import org.keycloak.testframework.ui.annotations.InjectPage; +import org.keycloak.testframework.ui.page.ErrorPage; +import org.keycloak.testframework.ui.page.PasswordPage; +import org.keycloak.testframework.ui.page.SelectAuthenticatorPage; +import org.keycloak.tests.utils.admin.AdminApiUtil; +import org.keycloak.testsuite.util.FlowUtil; +import org.keycloak.util.JsonSerialization; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.ALTERNATIVE; +import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +@KeycloakIntegrationTest +public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { + + @InjectRunOnServer(realmRef = "webauthn") + RunOnServerClient runOnServer; + + @InjectPage + ErrorPage errorPage; + + @InjectPage + PasswordPage passwordPage; + + @InjectPage + SelectAuthenticatorPage selectAuthenticatorPage; + + @BeforeEach + public void customizeWebAuthnTestRealm() { + List acceptableAaguids = new ArrayList<>(); + acceptableAaguids.add("00000000-0000-0000-0000-000000000000"); + acceptableAaguids.add("6d44ba9b-f6ec-2e49-b930-0c8fe920cb73"); + + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAcceptableAaguids(acceptableAaguids)); + } + + @Test + public void registerUserSuccess() { + String username = "registerUserSuccess"; + String email = "registerUserSuccess@email"; + String password = generatePassword(); + String userId; + + updateRealmWithDefaultWebAuthnSettings(); + + oAuthClient.openRegistrationForm(); + registerPage.assertCurrent(); + + String authenticatorLabel = SecretGenerator.getInstance().randomString(24); + registerPage.register("firstName", "lastName", email, username, password); + + // User was registered. Now he needs to register WebAuthn credential + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + webAuthnRegisterPage.registerWebAuthnCredential(authenticatorLabel); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + // confirm that registration is successfully completed + userId = AdminApiUtil.findUserByUsername(managedRealm.admin(), username).getId(); + + EventAssertion.assertSuccess(events.poll()).type(EventType.REGISTER).sessionId(null) + .userId(userId) + .clientId(Objects.requireNonNull(AdminApiUtil.findClientByClientId(managedRealm.admin(), "test-app")).toRepresentation().getClientId()) + .details(Details.USERNAME, username) + .details(Details.EMAIL, email) + .details(Details.REGISTER_METHOD, "form") + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()); + + + EventRepresentation event = events.poll(); + + EventAssertion.assertSuccess(event).type(EventType.CUSTOM_REQUIRED_ACTION).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, authenticatorLabel) + .details(WebAuthnConstants.PUBKEY_CRED_AAGUID_ATTR, ALL_ZERO_AAGUID); + + String regPubKeyCredentialId1 = event.getDetails().get(WebAuthnConstants.PUBKEY_CRED_ID_ATTR); + + EventRepresentation event2 = events.poll(); + + EventAssertion.assertSuccess(event2).type(EventType.UPDATE_CREDENTIAL).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, authenticatorLabel) + .details(WebAuthnConstants.PUBKEY_CRED_AAGUID_ATTR, ALL_ZERO_AAGUID); + + String regPubKeyCredentialId2 = event2.getDetails().get(WebAuthnConstants.PUBKEY_CRED_ID_ATTR); + + assertThat(regPubKeyCredentialId1, equalTo(regPubKeyCredentialId2)); + + // confirm login event + EventAssertion.assertSuccess(events.poll()).type(EventType.LOGIN).hasSessionId().userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, authenticatorLabel); + + // confirm user registered + assertUserRegistered(userId, username.toLowerCase(), email.toLowerCase()); + assertRegisteredCredentials(userId, ALL_ZERO_AAGUID, "none"); + + events.clear(); + + // logout by user + logout(); + + // confirm logout event + EventAssertion.assertSuccess(events.poll()).type(EventType.LOGOUT).hasSessionId().userId(userId) + .clientId(Objects.requireNonNull(AdminApiUtil.findClientByClientId(managedRealm.admin(), "account")).toRepresentation().getClientId()); + + // login by user + oAuthClient.openLoginForm(); + loginPage.fillLogin(username, password); + loginPage.submit(); + + assertThat(webAuthnLoginPage.getCount(), is(1)); + assertThat(webAuthnLoginPage.getLabels(), Matchers.contains(authenticatorLabel)); + + webAuthnLoginPage.clickAuthenticate(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + // confirm login event + EventAssertion.assertSuccess(events.poll()).type(EventType.LOGIN).hasSessionId().userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(WebAuthnConstants.PUBKEY_CRED_ID_ATTR, regPubKeyCredentialId2) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, Boolean.FALSE.toString()); + + events.clear(); + // logout by user + logout(); + + // confirm logout event + EventAssertion.assertSuccess(events.poll()).type(EventType.LOGOUT).hasSessionId().userId(userId) + .clientId(Objects.requireNonNull(AdminApiUtil.findClientByClientId(managedRealm.admin(), "account")).toRepresentation().getClientId()); + } + + @Test + public void webAuthnPasswordlessAlternativeWithWebAuthnAndPassword() { + String userId; + + final String WEBAUTHN_LABEL = "webauthn"; + final String PASSWORDLESS_LABEL = "passwordless"; + + managedRealm.updateWithCleanup(r -> r.browserFlow(webAuthnTogetherPasswordlessFlow())); + final UserRepresentation cleanupUser = AdminApiUtil.findUserByUsername(managedRealm.admin(), USERNAME); + managedRealm.cleanup().add(r -> r.users().get(cleanupUser.getId()).update(cleanupUser)); + + UserRepresentation user = AdminApiUtil.findUserByUsername(managedRealm.admin(), USERNAME); + + assertThat(user, notNullValue()); + user.getRequiredActions().add(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); + + UserResource userResource = managedRealm.admin().users().get(user.getId()); + assertThat(userResource, notNullValue()); + userResource.update(user); + + user = userResource.toRepresentation(); + assertThat(user, notNullValue()); + assertThat(user.getRequiredActions(), hasItem(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)); + + userId = user.getId(); + + oAuthClient.openLoginForm(); + loginUsernamePage.assertCurrent(); + loginUsernamePage.fillLoginWithUsernameOnly(USERNAME); + loginUsernamePage.submit(); + + passwordPage.assertCurrent(); + passwordPage.fillPassword(PASSWORD); + passwordPage.submit(); + + events.clear(); + + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + webAuthnRegisterPage.registerWebAuthnCredential(WEBAUTHN_LABEL); + + webAuthnRegisterPage.assertCurrent(); + + EventAssertion.assertSuccess(events.poll()).type(EventType.CUSTOM_REQUIRED_ACTION).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL); + + EventAssertion.assertSuccess(events.poll()).type(EventType.UPDATE_CREDENTIAL).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL); + + webAuthnRegisterPage.clickRegister(); + webAuthnRegisterPage.registerWebAuthnCredential(PASSWORDLESS_LABEL); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()).type(EventType.CUSTOM_REQUIRED_ACTION).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnPasswordlessRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, PASSWORDLESS_LABEL); + + EventAssertion.assertSuccess(events.poll()).type(EventType.UPDATE_CREDENTIAL).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnPasswordlessRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, PASSWORDLESS_LABEL); + + EventAssertion.assertSuccess(events.poll()).type(EventType.LOGIN).hasSessionId().userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); + + events.clear(); + + logout(); + + EventAssertion.assertSuccess(events.poll()).type(EventType.LOGOUT).hasSessionId().userId(userId) + .clientId(Objects.requireNonNull(AdminApiUtil.findClientByClientId(managedRealm.admin(), "account")).toRepresentation().getClientId()); + + // Password + WebAuthn Passkey + oAuthClient.openLoginForm(); + loginUsernamePage.assertCurrent(); + loginUsernamePage.fillLoginWithUsernameOnly(USERNAME); + loginUsernamePage.submit(); + + passwordPage.assertCurrent(); + passwordPage.fillPassword(PASSWORD); + passwordPage.submit(); + + webAuthnLoginPage.assertCurrent(); + + assertThat(webAuthnLoginPage.getCount(), is(1)); + assertThat(webAuthnLoginPage.getLabels(), Matchers.contains(WEBAUTHN_LABEL)); + + webAuthnLoginPage.clickAuthenticate(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + logout(); + + // Only passwordless login + oAuthClient.openLoginForm(); + loginUsernamePage.assertCurrent(); + loginUsernamePage.fillLoginWithUsernameOnly(USERNAME); + loginUsernamePage.submit(); + + passwordPage.assertCurrent(); + passwordPage.clickTryAnotherWayLink(); + + selectAuthenticatorPage.assertCurrent(); + assertThat(selectAuthenticatorPage.getLoginMethodHelpText(SelectAuthenticatorPage.SECURITY_KEY), + is("Use your Passkey for passwordless sign in.")); + selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.SECURITY_KEY); + + webAuthnLoginPage.assertCurrent(); + assertThat(webAuthnLoginPage.getCount(), is(0)); + + webAuthnLoginPage.clickAuthenticate(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + logout(); + } + + @Test + public void webAuthnPasswordlessShouldFailIfUserIsDeletedInBetween() { + final String WEBAUTHN_LABEL = "webauthn"; + final String PASSWORDLESS_LABEL = "passwordless"; + + managedRealm.updateWithCleanup(r -> r.browserFlow(webAuthnTogetherPasswordlessFlow())); + + String username = "webauthn-tester@localhost"; + String password = generatePassword(); + + UserRepresentation user = new UserRepresentation(); + user.setUsername(username); + user.setEnabled(true); + user.setFirstName("WebAuthN"); + user.setLastName("Tester"); + + String userId = AdminApiUtil.createUserAndResetPasswordWithAdminClient(managedRealm.admin(), user, password, false); + + user = AdminApiUtil.findUserByUsername(managedRealm.admin(), username); + + assertThat(user, notNullValue()); + user.getRequiredActions().add(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); + + UserResource userResource = managedRealm.admin().users().get(user.getId()); + assertThat(userResource, notNullValue()); + userResource.update(user); + + user = userResource.toRepresentation(); + assertThat(user, notNullValue()); + assertThat(user.getRequiredActions(), hasItem(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)); + + oAuthClient.openLoginForm(); + loginUsernamePage.assertCurrent(); + loginUsernamePage.fillLoginWithUsernameOnly(username); + loginUsernamePage.submit(); + + passwordPage.assertCurrent(); + passwordPage.fillPassword(password); + passwordPage.submit(); + + events.clear(); + + webAuthnRegisterPage.assertCurrent(); + webAuthnRegisterPage.clickRegister(); + webAuthnRegisterPage.registerWebAuthnCredential(WEBAUTHN_LABEL); + + webAuthnRegisterPage.assertCurrent(); + + EventAssertion.assertSuccess(events.poll()).type(EventType.CUSTOM_REQUIRED_ACTION).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL); + + EventAssertion.assertSuccess(events.poll()).type(EventType.UPDATE_CREDENTIAL).sessionId(null).userId(userId).isCodeId() + .details(Details.REDIRECT_URI, testApp.getRedirectionUri()) + .details(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) + .details(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL); + + webAuthnRegisterPage.clickRegister(); + webAuthnRegisterPage.registerWebAuthnCredential(PASSWORDLESS_LABEL); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + logout(); + + // Password + WebAuthn Passkey + oAuthClient.openLoginForm(); + loginUsernamePage.assertCurrent(); + loginUsernamePage.fillLoginWithUsernameOnly(username); + loginUsernamePage.submit(); + + passwordPage.assertCurrent(); + passwordPage.fillPassword(password); + passwordPage.submit(); + + webAuthnLoginPage.assertCurrent(); + + assertThat(webAuthnLoginPage.getCount(), is(1)); + assertThat(webAuthnLoginPage.getLabels(), Matchers.contains(WEBAUTHN_LABEL)); + + webAuthnLoginPage.clickAuthenticate(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + logout(); + + // Only passwordless login + oAuthClient.openLoginForm(); + loginUsernamePage.assertCurrent(); + loginUsernamePage.fillLoginWithUsernameOnly(username); + loginUsernamePage.submit(); + + passwordPage.assertCurrent(); + passwordPage.clickTryAnotherWayLink(); + + selectAuthenticatorPage.assertCurrent(); + assertThat(selectAuthenticatorPage.getLoginMethodHelpText(SelectAuthenticatorPage.SECURITY_KEY), + is("Use your Passkey for passwordless sign in.")); + selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.SECURITY_KEY); + + webAuthnLoginPage.assertCurrent(); + assertThat(webAuthnLoginPage.getCount(), is(0)); + + // remove testuser before user authenticates via webauthn + try (Response resp = managedRealm.admin().users().delete(userId)) { + // ignore + } + + webAuthnLoginPage.clickAuthenticate(); + + webAuthnErrorPage.assertCurrent(); + assertThat(webAuthnErrorPage.getError(), is("Unknown user authenticated by the Passkey.")); + } + + @Test + public void webAuthnTwoFactorAndWebAuthnPasswordlessTogether() { + // Change binding to browser-webauthn-passwordless. This is flow, which contains both "webauthn" and "webauthn-passwordless" authenticator + managedRealm.updateWithCleanup(r -> r.browserFlow("browser-webauthn-passwordless")); + // Login as webauthn-user with password + oAuthClient.openLoginForm(); + loginPage.fillLogin(USERNAME, PASSWORD); + loginPage.submit(); + + errorPage.assertCurrent(); + + // User is not allowed to register passwordless authenticator in this flow + assertThat(events.poll().getError(), is("invalid_user_credentials")); + assertThat(errorPage.getError(), is("Cannot login, credential setup required.")); + } + + private void assertUserRegistered(String userId, String username, String email) { + UserRepresentation user = getUser(userId); + assertThat(user, notNullValue()); + assertThat(user.getCreatedTimestamp(), notNullValue()); + + // test that timestamp is current with 60s tollerance + assertThat((System.currentTimeMillis() - user.getCreatedTimestamp()) < 60000, is(true)); + + // test user info is set from form + assertThat(user.getUsername(), is(username.toLowerCase())); + assertThat(user.getEmail(), is(email.toLowerCase())); + assertThat(user.getFirstName(), is("firstName")); + assertThat(user.getLastName(), is("lastName")); + } + + private void assertRegisteredCredentials(String userId, String aaguid, String attestationStatementFormat) { + List credentials = getCredentials(userId); + credentials.forEach(i -> { + if (WebAuthnCredentialModel.TYPE_TWOFACTOR.equals(i.getType())) { + try { + WebAuthnCredentialData data = JsonSerialization.readValue(i.getCredentialData(), WebAuthnCredentialData.class); + assertThat(data.getAaguid(), is(aaguid)); + assertThat(data.getAttestationStatementFormat(), is(attestationStatementFormat)); + } catch (IOException e) { + Assertions.fail(); + } + } + }); + } + + private UserRepresentation getUser(String userId) { + return managedRealm.admin().users().get(userId).toRepresentation(); + } + + private List getCredentials(String userId) { + return managedRealm.admin().users().get(userId).credentials(); + } + + private void updateRealmWithDefaultWebAuthnSettings() { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicySignatureAlgorithms(List.of("ES256"))); + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAttestationConveyancePreference("none")); + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAuthenticatorAttachment("cross-platform")); + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyRequireResidentKey("No")); + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyRpId(null)); + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyUserVerificationRequirement("preferred")); + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyAcceptableAaguids(List.of(ALL_ZERO_AAGUID))); + } + + /** + * This flow contains: + *

+ * UsernameForm REQUIRED + * Subflow REQUIRED + * ** WebAuthnPasswordlessAuthenticator ALTERNATIVE + * ** sub-subflow ALTERNATIVE + * **** PasswordForm ALTERNATIVE + * **** WebAuthnAuthenticator ALTERNATIVE + * + * @return flow alias + */ + private String webAuthnTogetherPasswordlessFlow() { + final String newFlowAlias = "browser-together-webauthn-flow"; + runOnServer.run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); + runOnServer.run(session -> { + FlowUtil.inCurrentRealm(session) + .selectFlow(newFlowAlias) + .inForms(forms -> forms + .clear() + .addAuthenticatorExecution(REQUIRED, UsernameFormFactory.PROVIDER_ID) + .addSubFlowExecution(REQUIRED, subFlow -> subFlow + .addAuthenticatorExecution(ALTERNATIVE, WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID) + .addSubFlowExecution(ALTERNATIVE, passwordFlow -> passwordFlow + .addAuthenticatorExecution(REQUIRED, PasswordFormFactory.PROVIDER_ID) + .addAuthenticatorExecution(REQUIRED, WebAuthnAuthenticatorFactory.PROVIDER_ID)) + )) + .defineAsBrowserFlow(); + }); + return newFlowAlias; + } +} diff --git a/tests/webauthn/src/test/resources/keycloak-test.properties b/tests/webauthn/src/test/resources/keycloak-test.properties new file mode 100644 index 00000000000..f6d949794be --- /dev/null +++ b/tests/webauthn/src/test/resources/keycloak-test.properties @@ -0,0 +1,17 @@ +kc.test.browser=chrome-headless + +kc.test.server=distribution + +kc.test.log.level=WARN + +kc.test.log.filter=true + +kc.test.log.category."org.keycloak.tests".level=INFO + +kc.test.log.category."testinfo".level=INFO +kc.test.log.category."org.keycloak.it".level=INFO +kc.test.log.category."org.keycloak".level=WARN +kc.test.log.category."managed.keycloak".level=WARN +kc.test.log.category."managed.db".level=WARN +kc.test.log.category."managed.infinispan".level=WARN +kc.test.log.category."org.keycloak.testframework.clustering".level=WARN \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java deleted file mode 100644 index bfb5bd9d0fe..00000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/WebAuthnRegisterAndLoginTest.java +++ /dev/null @@ -1,578 +0,0 @@ -/* - * Copyright 2019 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.keycloak.testsuite.webauthn; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -import jakarta.ws.rs.core.Response; - -import org.keycloak.WebAuthnConstants; -import org.keycloak.admin.client.resource.RealmResource; -import org.keycloak.admin.client.resource.UserResource; -import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; -import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; -import org.keycloak.authentication.authenticators.browser.WebAuthnAuthenticatorFactory; -import org.keycloak.authentication.authenticators.browser.WebAuthnPasswordlessAuthenticatorFactory; -import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; -import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory; -import org.keycloak.common.util.SecretGenerator; -import org.keycloak.events.Details; -import org.keycloak.events.EventType; -import org.keycloak.models.credential.WebAuthnCredentialModel; -import org.keycloak.models.credential.dto.WebAuthnCredentialData; -import org.keycloak.representations.idm.CredentialRepresentation; -import org.keycloak.representations.idm.EventRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.AbstractAdminTest; -import org.keycloak.testsuite.admin.ApiUtil; -import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; -import org.keycloak.testsuite.pages.AppPage.RequestType; -import org.keycloak.testsuite.pages.ErrorPage; -import org.keycloak.testsuite.pages.LoginUsernameOnlyPage; -import org.keycloak.testsuite.pages.PasswordPage; -import org.keycloak.testsuite.pages.SelectAuthenticatorPage; -import org.keycloak.testsuite.updaters.RealmAttributeUpdater; -import org.keycloak.testsuite.util.FlowUtil; -import org.keycloak.testsuite.webauthn.pages.WebAuthnAuthenticatorsList; -import org.keycloak.testsuite.webauthn.updaters.WebAuthnRealmAttributeUpdater; -import org.keycloak.util.JsonSerialization; - -import org.hamcrest.Matchers; -import org.jboss.arquillian.graphene.page.Page; -import org.junit.Assert; -import org.junit.Test; -import org.openqa.selenium.firefox.FirefoxDriver; - -import static org.keycloak.models.AuthenticationExecutionModel.Requirement.ALTERNATIVE; -import static org.keycloak.models.AuthenticationExecutionModel.Requirement.REQUIRED; - -import static org.hamcrest.CoreMatchers.equalTo; -import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -public class WebAuthnRegisterAndLoginTest extends AbstractWebAuthnVirtualTest { - - @Page - protected ErrorPage errorPage; - - @Page - protected LoginUsernameOnlyPage loginUsernamePage; - - @Page - protected PasswordPage passwordPage; - - @Page - protected SelectAuthenticatorPage selectAuthenticatorPage; - - @Override - public void addTestRealms(List testRealms) { - RealmRepresentation realmRepresentation = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/webauthn/testrealm-webauthn.json"), RealmRepresentation.class); - - List acceptableAaguids = new ArrayList<>(); - acceptableAaguids.add("00000000-0000-0000-0000-000000000000"); - acceptableAaguids.add("6d44ba9b-f6ec-2e49-b930-0c8fe920cb73"); - - realmRepresentation.setWebAuthnPolicyAcceptableAaguids(acceptableAaguids); - - testRealms.add(realmRepresentation); - configureTestRealm(realmRepresentation); - } - - @Test - @IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 - public void registerUserSuccess() throws IOException { - String username = "registerUserSuccess"; - String email = "registerUserSuccess@email"; - String userId = null; - - try (RealmAttributeUpdater rau = updateRealmWithDefaultWebAuthnSettings(testRealm()).update()) { - - loginPage.open(); - loginPage.clickRegister(); - registerPage.assertCurrent(); - - String authenticatorLabel = SecretGenerator.getInstance().randomString(24); - registerPage.register("firstName", "lastName", email, username, generatePassword(username)); - - // User was registered. Now he needs to register WebAuthn credential - webAuthnRegisterPage.assertCurrent(); - webAuthnRegisterPage.clickRegister(); - webAuthnRegisterPage.registerWebAuthnCredential(authenticatorLabel); - - appPage.assertCurrent(); - assertThat(appPage.getRequestType(), is(RequestType.AUTH_RESPONSE)); - appPage.openAccount(); - - // confirm that registration is successfully completed - userId = events.expectRegister(username, email).assertEvent().getUserId(); - // confirm registration event - EventRepresentation eventRep1 = events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, authenticatorLabel) - .detail(WebAuthnConstants.PUBKEY_CRED_AAGUID_ATTR, ALL_ZERO_AAGUID) - .assertEvent(); - EventRepresentation eventRep2 = events.expectRequiredAction(EventType.UPDATE_CREDENTIAL) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, authenticatorLabel) - .detail(WebAuthnConstants.PUBKEY_CRED_AAGUID_ATTR, ALL_ZERO_AAGUID) - .assertEvent(); - String regPubKeyCredentialId1 = eventRep1.getDetails().get(WebAuthnConstants.PUBKEY_CRED_ID_ATTR); - String regPubKeyCredentialId2 = eventRep2.getDetails().get(WebAuthnConstants.PUBKEY_CRED_ID_ATTR); - - assertThat(regPubKeyCredentialId1, equalTo(regPubKeyCredentialId2)); - - // confirm login event - String sessionId = events.expectLogin() - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, authenticatorLabel) - .assertEvent().getSessionId(); - // confirm user registered - assertUserRegistered(userId, username.toLowerCase(), email.toLowerCase()); - assertRegisteredCredentials(userId, ALL_ZERO_AAGUID, "none"); - - events.clear(); - - // logout by user - logout(); - - // confirm logout event - events.expectLogout(sessionId) - .removeDetail(Details.REDIRECT_URI) - .user(userId) - .client("account") - .assertEvent(); - - // login by user - loginPage.open(); - loginPage.login(username, getPassword(username)); - - webAuthnLoginPage.assertCurrent(); - - final WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); - assertThat(authenticators.getCount(), is(1)); - assertThat(authenticators.getLabels(), Matchers.contains(authenticatorLabel)); - - webAuthnLoginPage.clickAuthenticate(); - - appPage.assertCurrent(); - assertThat(appPage.getRequestType(), is(RequestType.AUTH_RESPONSE)); - appPage.openAccount(); - - // confirm login event - sessionId = events.expectLogin() - .user(userId) - .detail(WebAuthnConstants.PUBKEY_CRED_ID_ATTR, regPubKeyCredentialId2) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, Boolean.FALSE.toString()) - .assertEvent().getSessionId(); - - events.clear(); - // logout by user - logout(); - - // confirm logout event - events.expectLogout(sessionId) - .removeDetail(Details.REDIRECT_URI) - .client("account") - .user(userId) - .assertEvent(); - } finally { - removeFirstCredentialForUser(userId, WebAuthnCredentialModel.TYPE_TWOFACTOR); - } - } - - @Test - @IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 - public void webAuthnPasswordlessAlternativeWithWebAuthnAndPassword() throws IOException { - String userId = null; - - final String WEBAUTHN_LABEL = "webauthn"; - final String PASSWORDLESS_LABEL = "passwordless"; - - try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()) - .setBrowserFlow(webAuthnTogetherPasswordlessFlow()) - .update()) { - - UserRepresentation user = ApiUtil.findUserByUsername(testRealm(), "test-user@localhost"); - assertThat(user, notNullValue()); - user.getRequiredActions().add(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); - - UserResource userResource = testRealm().users().get(user.getId()); - assertThat(userResource, notNullValue()); - userResource.update(user); - - user = userResource.toRepresentation(); - assertThat(user, notNullValue()); - assertThat(user.getRequiredActions(), hasItem(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)); - - userId = user.getId(); - - loginUsernamePage.open(); - loginUsernamePage.login("test-user@localhost"); - - passwordPage.assertCurrent(); - passwordPage.login(getPassword("test-user@localhost")); - - events.clear(); - - webAuthnRegisterPage.assertCurrent(); - webAuthnRegisterPage.clickRegister(); - webAuthnRegisterPage.registerWebAuthnCredential(WEBAUTHN_LABEL); - - webAuthnRegisterPage.assertCurrent(); - - events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL) - .assertEvent(); - events.expectRequiredAction(EventType.UPDATE_CREDENTIAL) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL) - .assertEvent(); - - webAuthnRegisterPage.clickRegister(); - webAuthnRegisterPage.registerWebAuthnCredential(PASSWORDLESS_LABEL); - - appPage.assertCurrent(); - - events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnPasswordlessRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, PASSWORDLESS_LABEL) - .assertEvent(); - events.expectRequiredAction(EventType.UPDATE_CREDENTIAL) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnPasswordlessRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, PASSWORDLESS_LABEL) - .assertEvent(); - - final String sessionID = events.expectLogin() - .user(userId) - .assertEvent() - .getSessionId(); - - events.clear(); - - logout(); - - events.expectLogout(sessionID) - .removeDetail(Details.REDIRECT_URI) - .user(userId) - .client("account") - .assertEvent(); - - // Password + WebAuthn Passkey - loginUsernamePage.open(); - loginUsernamePage.assertCurrent(); - loginUsernamePage.login("test-user@localhost"); - - passwordPage.assertCurrent(); - passwordPage.login(getPassword("test-user@localhost")); - - webAuthnLoginPage.assertCurrent(); - - final WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); - assertThat(authenticators.getCount(), is(1)); - assertThat(authenticators.getLabels(), Matchers.contains(WEBAUTHN_LABEL)); - - webAuthnLoginPage.clickAuthenticate(); - - appPage.assertCurrent(); - logout(); - - // Only passwordless login - loginUsernamePage.open(); - loginUsernamePage.login("test-user@localhost"); - - passwordPage.assertCurrent(); - passwordPage.assertTryAnotherWayLinkAvailability(true); - passwordPage.clickTryAnotherWayLink(); - - selectAuthenticatorPage.assertCurrent(); - assertThat(selectAuthenticatorPage.getLoginMethodHelpText(SelectAuthenticatorPage.SECURITY_KEY), - is("Use your Passkey for passwordless sign in.")); - selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.SECURITY_KEY); - - webAuthnLoginPage.assertCurrent(); - assertThat(webAuthnLoginPage.getAuthenticators().getCount(), is(0)); - - webAuthnLoginPage.clickAuthenticate(); - - appPage.assertCurrent(); - logout(); - } finally { - removeFirstCredentialForUser(userId, WebAuthnCredentialModel.TYPE_TWOFACTOR, WEBAUTHN_LABEL); - removeFirstCredentialForUser(userId, WebAuthnCredentialModel.TYPE_PASSWORDLESS, PASSWORDLESS_LABEL); - } - } - - // See: https://github.com/keycloak/keycloak/issues/29586 - @Test - @IgnoreBrowserDriver(FirefoxDriver.class) - public void webAuthnPasswordlessShouldFailIfUserIsDeletedInBetween() throws IOException { - - final String WEBAUTHN_LABEL = "webauthn"; - final String PASSWORDLESS_LABEL = "passwordless"; - - RealmResource realmResource = testRealm(); - - try (RealmAttributeUpdater rau = new RealmAttributeUpdater(realmResource) - .setBrowserFlow(webAuthnTogetherPasswordlessFlow()) - .update()) { - - String username = "webauthn-tester@localhost"; - String password = generatePassword("webauthn-tester@localhost"); - - UserRepresentation user = new UserRepresentation(); - user.setUsername(username); - user.setEnabled(true); - user.setFirstName("WebAuthN"); - user.setLastName("Tester"); - - String userId = ApiUtil.createUserAndResetPasswordWithAdminClient(realmResource, user, password, false); - - user = ApiUtil.findUserByUsername(realmResource, username); - - assertThat(user, notNullValue()); - user.getRequiredActions().add(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); - - UserResource userResource = realmResource.users().get(user.getId()); - assertThat(userResource, notNullValue()); - userResource.update(user); - - user = userResource.toRepresentation(); - assertThat(user, notNullValue()); - assertThat(user.getRequiredActions(), hasItem(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)); - - loginUsernamePage.open(); - loginUsernamePage.login(username); - - passwordPage.assertCurrent(); - passwordPage.login(password); - - events.clear(); - - webAuthnRegisterPage.assertCurrent(); - webAuthnRegisterPage.clickRegister(); - webAuthnRegisterPage.registerWebAuthnCredential(WEBAUTHN_LABEL); - - webAuthnRegisterPage.assertCurrent(); - - events.expectRequiredAction(EventType.CUSTOM_REQUIRED_ACTION) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL) - .assertEvent(); - events.expectRequiredAction(EventType.UPDATE_CREDENTIAL) - .user(userId) - .detail(Details.CUSTOM_REQUIRED_ACTION, WebAuthnRegisterFactory.PROVIDER_ID) - .detail(WebAuthnConstants.PUBKEY_CRED_LABEL_ATTR, WEBAUTHN_LABEL) - .assertEvent(); - - webAuthnRegisterPage.clickRegister(); - webAuthnRegisterPage.registerWebAuthnCredential(PASSWORDLESS_LABEL); - - appPage.assertCurrent(); - - logout(); - - // Password + WebAuthn Passkey - loginUsernamePage.open(); - loginUsernamePage.assertCurrent(); - loginUsernamePage.login(username); - - passwordPage.assertCurrent(); - passwordPage.login(password); - - webAuthnLoginPage.assertCurrent(); - - final WebAuthnAuthenticatorsList authenticators = webAuthnLoginPage.getAuthenticators(); - assertThat(authenticators.getCount(), is(1)); - assertThat(authenticators.getLabels(), Matchers.contains(WEBAUTHN_LABEL)); - - webAuthnLoginPage.clickAuthenticate(); - - appPage.assertCurrent(); - logout(); - - // Only passwordless login - loginUsernamePage.open(); - loginUsernamePage.login(username); - - passwordPage.assertCurrent(); - passwordPage.assertTryAnotherWayLinkAvailability(true); - passwordPage.clickTryAnotherWayLink(); - - selectAuthenticatorPage.assertCurrent(); - assertThat(selectAuthenticatorPage.getLoginMethodHelpText(SelectAuthenticatorPage.SECURITY_KEY), - is("Use your Passkey for passwordless sign in.")); - selectAuthenticatorPage.selectLoginMethod(SelectAuthenticatorPage.SECURITY_KEY); - - webAuthnLoginPage.assertCurrent(); - assertThat(webAuthnLoginPage.getAuthenticators().getCount(), is(0)); - - // remove testuser before user authenticates via webauthn - try (Response resp = realmResource.users().delete(userId)) { - // ignore - } - - webAuthnLoginPage.clickAuthenticate(); - - webAuthnErrorPage.assertCurrent(); - assertThat(webAuthnErrorPage.getError(), is("Unknown user authenticated by the Passkey.")); - } - } - - @Test - public void webAuthnTwoFactorAndWebAuthnPasswordlessTogether() throws IOException { - // Change binding to browser-webauthn-passwordless. This is flow, which contains both "webauthn" and "webauthn-passwordless" authenticator - try (RealmAttributeUpdater rau = new RealmAttributeUpdater(testRealm()).setBrowserFlow("browser-webauthn-passwordless").update()) { - // Login as test-user@localhost with password - loginPage.open(); - loginPage.login("test-user@localhost", getPassword("test-user@localhost")); - - errorPage.assertCurrent(); - - // User is not allowed to register passwordless authenticator in this flow - assertThat(events.poll().getError(), is("invalid_user_credentials")); - assertThat(errorPage.getError(), is("Cannot login, credential setup required.")); - } - } - - private void assertUserRegistered(String userId, String username, String email) { - UserRepresentation user = getUser(userId); - assertThat(user, notNullValue()); - assertThat(user.getCreatedTimestamp(), notNullValue()); - - // test that timestamp is current with 60s tollerance - assertThat((System.currentTimeMillis() - user.getCreatedTimestamp()) < 60000, is(true)); - - // test user info is set from form - assertThat(user.getUsername(), is(username.toLowerCase())); - assertThat(user.getEmail(), is(email.toLowerCase())); - assertThat(user.getFirstName(), is("firstName")); - assertThat(user.getLastName(), is("lastName")); - } - - private void assertRegisteredCredentials(String userId, String aaguid, String attestationStatementFormat) { - List credentials = getCredentials(userId); - credentials.forEach(i -> { - if (WebAuthnCredentialModel.TYPE_TWOFACTOR.equals(i.getType())) { - try { - WebAuthnCredentialData data = JsonSerialization.readValue(i.getCredentialData(), WebAuthnCredentialData.class); - assertThat(data.getAaguid(), is(aaguid)); - assertThat(data.getAttestationStatementFormat(), is(attestationStatementFormat)); - } catch (IOException e) { - Assert.fail(); - } - } - }); - } - - protected UserRepresentation getUser(String userId) { - return testRealm().users().get(userId).toRepresentation(); - } - - protected List getCredentials(String userId) { - return testRealm().users().get(userId).credentials(); - } - - private static WebAuthnRealmAttributeUpdater updateRealmWithDefaultWebAuthnSettings(RealmResource resource) { - return new WebAuthnRealmAttributeUpdater(resource) - .setWebAuthnPolicySignatureAlgorithms(Collections.singletonList("ES256")) - .setWebAuthnPolicyAttestationConveyancePreference("none") - .setWebAuthnPolicyAuthenticatorAttachment("cross-platform") - .setWebAuthnPolicyRequireResidentKey("No") - .setWebAuthnPolicyRpId(null) - .setWebAuthnPolicyUserVerificationRequirement("preferred") - .setWebAuthnPolicyAcceptableAaguids(Collections.singletonList(ALL_ZERO_AAGUID)); - } - - /** - * This flow contains: - *

- * UsernameForm REQUIRED - * Subflow REQUIRED - * ** WebAuthnPasswordlessAuthenticator ALTERNATIVE - * ** sub-subflow ALTERNATIVE - * **** PasswordForm ALTERNATIVE - * **** WebAuthnAuthenticator ALTERNATIVE - * - * @return flow alias - */ - private String webAuthnTogetherPasswordlessFlow() { - final String newFlowAlias = "browser-together-webauthn-flow"; - testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias)); - testingClient.server(TEST_REALM_NAME).run(session -> { - FlowUtil.inCurrentRealm(session) - .selectFlow(newFlowAlias) - .inForms(forms -> forms - .clear() - .addAuthenticatorExecution(REQUIRED, UsernameFormFactory.PROVIDER_ID) - .addSubFlowExecution(REQUIRED, subFlow -> subFlow - .addAuthenticatorExecution(ALTERNATIVE, WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID) - .addSubFlowExecution(ALTERNATIVE, passwordFlow -> passwordFlow - .addAuthenticatorExecution(REQUIRED, PasswordFormFactory.PROVIDER_ID) - .addAuthenticatorExecution(REQUIRED, WebAuthnAuthenticatorFactory.PROVIDER_ID)) - )) - .defineAsBrowserFlow(); - }); - return newFlowAlias; - } - - private void removeFirstCredentialForUser(String userId, String credentialType) { - removeFirstCredentialForUser(userId, credentialType, null); - } - - /** - * Remove first occurring credential from user with specific credentialType - * - * @param userId userId - * @param credentialType type of credential - * @param assertUserLabel user label of credential - */ - private void removeFirstCredentialForUser(String userId, String credentialType, String assertUserLabel) { - if (userId == null || credentialType == null) return; - - final UserResource userResource = testRealm().users().get(userId); - - final CredentialRepresentation credentialRep = userResource.credentials() - .stream() - .filter(Objects::nonNull) - .filter(credential -> credentialType.equals(credential.getType())) - .findFirst() - .orElse(null); - - if (credentialRep != null) { - if (assertUserLabel != null) { - assertThat(credentialRep.getUserLabel(), is(assertUserLabel)); - } - userResource.removeCredential(credentialRep.getId()); - } - } -}