[Test Framework] Migrate initial WebAuthn setup + WebAuthnRegisterAndLoginTest. (#44016)

Signed-off-by: Lukas Hanusovsky <lhanusov@redhat.com>
This commit is contained in:
Lukas Hanusovsky
2025-12-15 15:01:42 +01:00
committed by GitHub
parent 29fdcedbc8
commit e8c6a7b98d
25 changed files with 2417 additions and 587 deletions

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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<String> 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<String> 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<String> 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

View File

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

View File

@@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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";
}
}

View File

@@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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";
}
}

View File

@@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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<String, String> 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<String, String> 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";
}
}

View File

@@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
* @author <a href="mailto:pzaoral@redhat.com">Peter Zaoral</a>
*/
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<WebElement> 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";
}
}

View File

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

View File

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

View File

@@ -39,6 +39,7 @@
<module>custom-providers</module>
<module>custom-scripts</module>
<module>clustering</module>
<module>webauthn</module>
</modules>
<profiles>

106
tests/webauthn/pom.xml Normal file
View File

@@ -0,0 +1,106 @@
<?xml version="1.0"?>
<!--
~ 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.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>keycloak-tests-parent</artifactId>
<groupId>org.keycloak.tests</groupId>
<version>999.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>keycloak-tests-webauthn</artifactId>
<name>New Keycloak WebAuthn Testsuite</name>
<packaging>jar</packaging>
<description>New Keycloak WebAuthn Testsuite</description>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-bom</artifactId>
<version>${project.version}</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-core</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-junit5-config</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-ui</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-oauth</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-remote</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak.tests</groupId>
<artifactId>keycloak-tests-utils</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak.tests</groupId>
<artifactId>keycloak-tests-utils-shared</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.logmanager</groupId>
<artifactId>jboss-logmanager</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<java.util.concurrent.ForkJoinPool.common.threadFactory>io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory</java.util.concurrent.ForkJoinPool.common.threadFactory>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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<VirtualAuthenticatorOptions> options;
DefaultVirtualAuthOptions(Supplier<VirtualAuthenticatorOptions> 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);
}
}

View File

@@ -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
* <p>
* Used as wrapper for VirtualAuthenticator and its options*
*
* @author <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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<String, Object> 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<String, Object> asMap() {
return map;
}
private static VirtualAuthenticatorOptions.Protocol protocolFromMap(Map<String, Object> map) {
return Arrays.stream(VirtualAuthenticatorOptions.Protocol.values())
.filter(f -> f.id.equals(map.get("protocol")))
.findFirst().orElse(null);
}
private static VirtualAuthenticatorOptions.Transport transportFromMap(Map<String, Object> map) {
return Arrays.stream(VirtualAuthenticatorOptions.Transport.values())
.filter(f -> f.id.equals(map.get("transport")))
.findFirst().orElse(null);
}
}
}

View File

@@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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();
}

View File

@@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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;
}
}
}

View File

@@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
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";
}
}

View File

@@ -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<WebElement> authenticatorsLabels;
@FindBy(xpath = "//div[contains(@id,'kc-webauthn-authenticator-item-')]")
private List<WebElement> authenticators;
public WebAuthnLoginPage(ManagedWebDriver driver) {
super(driver);
}
public void clickAuthenticate() {
authenticateButton.click();
}
public List<WebAuthnAuthenticatorItem> getItems() {
try {
List<WebAuthnAuthenticatorItem> 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<String> 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;
}
}
}

View File

@@ -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.
* <p>
* 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";
}
}

View File

@@ -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 <a href="mailto:mabartos@redhat.com">Martin Bartos</a>
*/
@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<AuthenticationFlowRepresentation> 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<AuthenticationFlowRepresentation> 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<AuthenticationExecutionExportRepresentation> 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<Class<? extends WebDriver>, 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<Class<? extends WebDriver>, 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;
}
}
}

View File

@@ -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<String> 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<CredentialRepresentation> 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<CredentialRepresentation> 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:
* <p>
* 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;
}
}

View File

@@ -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

View File

@@ -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<RealmRepresentation> testRealms) {
RealmRepresentation realmRepresentation = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/webauthn/testrealm-webauthn.json"), RealmRepresentation.class);
List<String> 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<CredentialRepresentation> 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<CredentialRepresentation> 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:
* <p>
* 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());
}
}
}