From 95cdee91aac1bbff80c520ec40a96fd8ecea8096 Mon Sep 17 00:00:00 2001 From: Ricardo Martin Date: Wed, 15 Apr 2026 15:28:56 +0200 Subject: [PATCH] Pass and use rememberMe option in passkeys authenticators Closes #45104 --- .../AbstractUsernameFormAuthenticator.java | 12 +- .../WebAuthnPasswordlessAuthenticator.java | 6 + .../util/AuthenticatorUtils.java | 21 + .../testframework/events/EventAssertion.java | 8 + .../realm/RealmConfigBuilder.java | 5 + .../ui/page/AbstractLoginPage.java | 23 + .../testframework/ui/page/LoginPage.java | 22 +- .../ui/page/LoginUsernamePage.java | 35 ++ .../tests/oauth/RefreshTokenTest.java | 2 +- .../webauthn/AbstractWebAuthnVirtualTest.java | 126 ++--- .../PasskeysUsernameFormTest.java | 498 ++++++++++++++++++ .../PasskeysUsernamePasswordFormTest.java | 483 +++++++++++++++++ .../PasskeysUsernameFormTest.java | 396 -------------- .../PasskeysUsernamePasswordFormTest.java | 421 --------------- .../resources/js/webauthnAuthenticate.js | 8 + 15 files changed, 1148 insertions(+), 918 deletions(-) create mode 100644 tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java create mode 100644 tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java delete mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java index db9c8016dcd..719ebf25180 100755 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/AbstractUsernameFormAuthenticator.java @@ -38,7 +38,6 @@ import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.messages.Messages; import org.keycloak.sessions.AuthenticationSessionModel; -import static org.keycloak.authentication.authenticators.util.AuthenticatorUtils.getDisabledByBruteForceEventError; import static org.keycloak.services.validation.Validation.FIELD_PASSWORD; import static org.keycloak.services.validation.Validation.FIELD_USERNAME; @@ -200,14 +199,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth if (!enabledUser(context, user)) { return false; } - String rememberMe = inputData.getFirst("rememberMe"); - boolean remember = context.getRealm().isRememberMe() && rememberMe != null && rememberMe.equalsIgnoreCase("on"); - if (remember) { - context.getAuthenticationSession().setAuthNote(Details.REMEMBER_ME, "true"); - context.getEvent().detail(Details.REMEMBER_ME, "true"); - } else { - context.getAuthenticationSession().removeAuthNote(Details.REMEMBER_ME); - } + AuthenticatorUtils.processRememberMe(context, inputData); context.setUser(user); return true; } @@ -249,7 +241,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth } protected boolean isDisabledByBruteForce(AuthenticationFlowContext context, UserModel user) { - String bruteForceError = getDisabledByBruteForceEventError(context, user); + String bruteForceError = AuthenticatorUtils.getDisabledByBruteForceEventError(context, user); if (bruteForceError != null) { context.getEvent().user(user); context.getEvent().error(bruteForceError); diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java index 644e99d3276..091537b1488 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/WebAuthnPasswordlessAuthenticator.java @@ -27,6 +27,7 @@ import org.keycloak.WebAuthnConstants; import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.authentication.RequiredActionFactory; import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.authentication.authenticators.util.AuthenticatorUtils; import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory; import org.keycloak.credential.CredentialProvider; import org.keycloak.credential.WebAuthnPasswordlessCredentialProvider; @@ -112,6 +113,11 @@ public class WebAuthnPasswordlessAuthenticator extends WebAuthnAuthenticator { return; } + // process rememberMe if present + if (formData.containsKey("rememberMe")) { + AuthenticatorUtils.processRememberMe(context, formData); + } + // user selected a webauthn credential, proceed with webauthn authentication super.action(context); } diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java index 36cfc940e66..4e2d6802e38 100644 --- a/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java +++ b/services/src/main/java/org/keycloak/authentication/authenticators/util/AuthenticatorUtils.java @@ -20,9 +20,12 @@ package org.keycloak.authentication.authenticators.util; import java.io.IOException; import java.util.Map; +import jakarta.ws.rs.core.MultivaluedMap; + import org.keycloak.authentication.AuthenticationFlowContext; import org.keycloak.common.util.Time; import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.models.Constants; @@ -138,4 +141,22 @@ public final class AuthenticatorUtils { } } + /** + * Process the rememberMe input for authentication. If the inputData contains + * the rememberMe attribute set to on and the realm is + * configured with the rememberMe option, the auth note is added to the + * authentication session; otherwise, the note is removed from the auth session. + * @param context The flow context + * @param inputData The form data + */ + public static void processRememberMe(AuthenticationFlowContext context, MultivaluedMap inputData) { + String rememberMe = inputData.getFirst("rememberMe"); + boolean remember = context.getRealm().isRememberMe() && rememberMe != null && rememberMe.equalsIgnoreCase("on"); + if (remember) { + context.getAuthenticationSession().setAuthNote(Details.REMEMBER_ME, "true"); + context.getEvent().detail(Details.REMEMBER_ME, "true"); + } else { + context.getAuthenticationSession().removeAuthNote(Details.REMEMBER_ME); + } + } } diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java b/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java index b7a12de5332..d1181e54cb0 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/events/EventAssertion.java @@ -163,4 +163,12 @@ public class EventAssertion { return this; } + /** + * Return the event associated to the assertion. + * + * @return the asserted {@link EventRepresentation} + */ + public EventRepresentation getEvent() { + return event; + } } diff --git a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java index 0c27c2a3af1..187bd28b255 100644 --- a/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java +++ b/test-framework/core/src/main/java/org/keycloak/testframework/realm/RealmConfigBuilder.java @@ -454,6 +454,11 @@ public class RealmConfigBuilder { return this; } + public RealmConfigBuilder webAuthnPolicyPasswordlessPasskeysEnabled(Boolean enabled) { + rep.setWebAuthnPolicyPasswordlessPasskeysEnabled(enabled); + return this; + } + public RealmConfigBuilder webAuthnPolicyAcceptableAaguids(List aaguids) { rep.setWebAuthnPolicyAcceptableAaguids(aaguids); return this; diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/AbstractLoginPage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/AbstractLoginPage.java index 49e0143c6c8..3943ef5546a 100644 --- a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/AbstractLoginPage.java +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/AbstractLoginPage.java @@ -17,6 +17,8 @@ package org.keycloak.testframework.ui.page; +import java.util.Optional; + import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; import org.openqa.selenium.By; @@ -41,6 +43,12 @@ public abstract class AbstractLoginPage extends AbstractPage { @FindBy(id = "kc-attempted-username") // Username during re-authentication private WebElement attemptedUsernameLabel; + @FindBy(className = "pf-m-info") + private WebElement loginInfoMessage; + + @FindBy(className = "pf-m-danger") + private WebElement loginErrorMessage; + public AbstractLoginPage(ManagedWebDriver driver) { super(driver); } @@ -76,4 +84,19 @@ public abstract class AbstractLoginPage extends AbstractPage { } } + public Optional getInfoMessage() { + try { + return Optional.of(loginInfoMessage.getText()); + } catch (NoSuchElementException e) { + return Optional.empty(); + } + } + + public Optional getErrorMessage() { + try { + return Optional.of(loginErrorMessage.getText()); + } catch (NoSuchElementException e) { + return Optional.empty(); + } + } } diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginPage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginPage.java index d5bcd87f711..fe18ea5fd94 100644 --- a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginPage.java +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginPage.java @@ -1,5 +1,7 @@ package org.keycloak.testframework.ui.page; +import java.util.Optional; + import org.keycloak.testframework.ui.webdriver.ManagedWebDriver; import org.openqa.selenium.By; @@ -30,8 +32,8 @@ public class LoginPage extends AbstractLoginPage { @FindBy(id = "input-error-username") private WebElement userNameInputError; - @FindBy(className = "pf-m-danger") - private WebElement loginErrorMessage; + @FindBy(id = "input-error-password") + private WebElement passwordInputError; public LoginPage(ManagedWebDriver driver) { super(driver); @@ -44,6 +46,11 @@ public class LoginPage extends AbstractLoginPage { passwordInput.sendKeys(password); } + public void fillPassword(String password) { + passwordInput.clear(); + passwordInput.sendKeys(password); + } + public void submit() { submitButton.click(); } @@ -86,6 +93,10 @@ public class LoginPage extends AbstractLoginPage { return usernameInput.getAttribute("value"); } + public String getUsernameAutocomplete() { + return usernameInput.getDomAttribute("autocomplete"); + } + public void clearUsernameInput() { usernameInput.clear(); } @@ -98,12 +109,11 @@ public class LoginPage extends AbstractLoginPage { } } - public String getError() { + public Optional getPasswordInputError() { try { - return loginErrorMessage.getText(); + return Optional.of(passwordInputError.getText()); } catch (NoSuchElementException e) { - return null; + return Optional.empty(); } } - } diff --git a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java index 0dc5ba5873e..2f9e48f2642 100644 --- a/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java +++ b/test-framework/ui/src/main/java/org/keycloak/testframework/ui/page/LoginUsernamePage.java @@ -2,6 +2,7 @@ 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; @@ -13,14 +14,37 @@ public class LoginUsernamePage extends AbstractLoginPage { @FindBy(css = "[type=submit]") private WebElement submitButton; + @FindBy(id = "input-error-username") + private WebElement userNameInputError; + + @FindBy(id = "rememberMe") + private WebElement rememberMe; + public LoginUsernamePage(ManagedWebDriver driver) { super(driver); } public void fillLoginWithUsernameOnly(String username) { + usernameInput.clear(); usernameInput.sendKeys(username); } + public String getUsername() { + return usernameInput.getAttribute("value"); + } + + public String getUsernameAutocomplete() { + return usernameInput.getDomAttribute("autocomplete"); + } + + public String getUsernameInputError() { + try { + return userNameInputError.getText(); + } catch (NoSuchElementException e) { + return null; + } + } + public void submit() { submitButton.click(); } @@ -29,4 +53,15 @@ public class LoginUsernamePage extends AbstractLoginPage { public String getExpectedPageId() { return "login-login-username"; } + + public void rememberMe(boolean value) { + boolean selected = isRememberMe(); + if ((value && !selected) || !value && selected) { + rememberMe.click(); + } + } + + public boolean isRememberMe() { + return rememberMe.isSelected(); + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java index 1550def7b23..1266ef47503 100755 --- a/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oauth/RefreshTokenTest.java @@ -582,7 +582,7 @@ public class RefreshTokenTest { oauth.openLoginForm(); driver.cookies().add(authSessionCookie); oauth.fillLoginForm("bob", "bob"); - Assert.assertEquals("Your login attempt timed out. Login will start from the beginning.", loginPage.getError()); + Assert.assertEquals("Your login attempt timed out. Login will start from the beginning.", loginPage.getErrorMessage().orElse(null)); } finally { realmResource.remove(); oauth.realm(origRealm); diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java index 144ae146347..1ebc56b3170 100644 --- a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/AbstractWebAuthnVirtualTest.java @@ -26,16 +26,12 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import org.keycloak.admin.client.resource.AuthenticationManagementResource; 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; @@ -91,19 +87,19 @@ import static org.hamcrest.MatcherAssert.assertThat; public abstract class AbstractWebAuthnVirtualTest implements UseVirtualAuthenticators { @InjectRealm(ref = "webauthn", config = WebAuthnRealmConfig.class) - ManagedRealm managedRealm; + protected ManagedRealm managedRealm; @InjectEvents(realmRef = "webauthn") - Events events; + protected Events events; @InjectOAuthClient(realmRef = "webauthn") - OAuthClient oAuthClient; + protected OAuthClient oAuthClient; @InjectTestApp - TestApp testApp; + protected TestApp testApp; @InjectWebDriver - ManagedWebDriver driver; + protected ManagedWebDriver driver; @InjectPage protected LoginPage loginPage; @@ -150,12 +146,10 @@ public abstract class AbstractWebAuthnVirtualTest implements UseVirtualAuthentic @BeforeEach public void initWebAuthnTestRealm() { - RealmRepresentation realmRep = managedRealm.admin().toRepresentation(); if (isPasswordless()) { - makePasswordlessRequiredActionDefault(realmRep); - switchExecutionInBrowserFormToPasswordless(realmRep); + makePasswordlessRequiredActionDefault(); + switchExecutionInBrowserFormToPasswordless(); } - managedRealm.updateWithCleanup(r -> r.update(realmRep)); setUpVirtualAuthenticator(); } @@ -323,87 +317,30 @@ public abstract class AbstractWebAuthnVirtualTest implements UseVirtualAuthentic 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); + protected void makePasswordlessRequiredActionDefault() { + AuthenticationManagementResource authRes = managedRealm.admin().flows(); + RequiredActionProviderRepresentation webAuthnProvider = authRes.getRequiredAction(WebAuthnRegisterFactory.PROVIDER_ID); assertThat(webAuthnProvider, notNullValue()); webAuthnProvider.setEnabled(false); + authRes.updateRequiredAction(webAuthnProvider.getAlias(), webAuthnProvider); - RequiredActionProviderRepresentation webAuthnPasswordlessProvider = realm.getRequiredActions() - .stream() - .filter(f -> f.getProviderId().equals(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID)) - .findFirst() - .orElse(null); + webAuthnProvider.setEnabled(true); + managedRealm.cleanup().add(r -> r.flows().updateRequiredAction(webAuthnProvider.getAlias(), webAuthnProvider)); + + RequiredActionProviderRepresentation webAuthnPasswordlessProvider = authRes.getRequiredAction(WebAuthnPasswordlessRegisterFactory.PROVIDER_ID); assertThat(webAuthnPasswordlessProvider, notNullValue()); - webAuthnPasswordlessProvider.setEnabled(true); webAuthnPasswordlessProvider.setDefaultAction(true); - } + authRes.updateRequiredAction(webAuthnPasswordlessProvider.getAlias(), webAuthnPasswordlessProvider); - /** - * Changes the flow "browser-webauthn-forms" to use the passed authenticator as required. - * @param realm The realm representation - * @param providerId The provider Id to set as required - */ - protected void switchExecutionInBrowserFormToProvider(RealmRepresentation realm, String providerId) { - List flows = realm.getAuthenticationFlows(); - assertThat(flows, notNullValue()); - - AuthenticationFlowRepresentation browserForm = flows.stream() - .filter(f -> f.getAlias().equals("browser-webauthn-forms")) - .findFirst() - .orElse(null); - assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, notNullValue()); - - flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias())); - - // set just one authenticator with the passkeys conditional UI - AuthenticationExecutionExportRepresentation passkeysConditionalUI = new AuthenticationExecutionExportRepresentation(); - passkeysConditionalUI.setAuthenticator(providerId); - passkeysConditionalUI.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); - passkeysConditionalUI.setPriority(10); - passkeysConditionalUI.setAuthenticatorFlow(false); - passkeysConditionalUI.setUserSetupAllowed(false); - - browserForm.setAuthenticationExecutions(List.of(passkeysConditionalUI)); - flows.add(browserForm); - - realm.setAuthenticationFlows(flows); + webAuthnPasswordlessProvider.setDefaultAction(false); + managedRealm.cleanup().add(r -> r.flows().updateRequiredAction(webAuthnPasswordlessProvider.getAlias(), webAuthnPasswordlessProvider)); } // Switch WebAuthn authenticator with Passwordless authenticator in browser flow - protected void switchExecutionInBrowserFormToPasswordless(RealmRepresentation realm) { - List flows = realm.getAuthenticationFlows(); - assertThat(flows, notNullValue()); - - AuthenticationFlowRepresentation browserForm = flows.stream() - .filter(f -> f.getAlias().equals("browser-webauthn-forms")) - .findFirst() - .orElse(null); - assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, notNullValue()); - - flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias())); - - List browserFormExecutions = browserForm.getAuthenticationExecutions(); - assertThat("Flow 'browser-webauthn-forms' doesn't have any executions", browserForm, notNullValue()); - - AuthenticationExecutionExportRepresentation webAuthn = browserFormExecutions.stream() - .filter(f -> WebAuthnAuthenticatorFactory.PROVIDER_ID.equals(f.getAuthenticator())) - .findFirst() - .orElse(null); - assertThat("Cannot find WebAuthn execution in Browser flow", webAuthn, notNullValue()); - - browserFormExecutions.removeIf(f -> webAuthn.getAuthenticator().equals(f.getAuthenticator())); - webAuthn.setAuthenticator(WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID); - browserFormExecutions.add(webAuthn); - browserForm.setAuthenticationExecutions(browserFormExecutions); - flows.add(browserForm); - - realm.setAuthenticationFlows(flows); + protected void switchExecutionInBrowserFormToPasswordless() { + managedRealm.updateWithCleanup(r -> r.browserFlow("browser-webauthn-passwordless")); } protected void logout() { @@ -491,6 +428,20 @@ public abstract class AbstractWebAuthnVirtualTest implements UseVirtualAuthentic flowBuilder5.addAuthenticationExecutionWithAuthenticator("webauthn-authenticator", "REQUIRED", 20, false); flowBuilder5.addAuthenticationExecutionWithAuthenticator("webauthn-authenticator-passwordless", "REQUIRED", 30, false); + // passkeys-username-forms + AuthenticationFlowConfigBuilder flowBuilder6 = builder.addAuthenticationFlow("passkeys-username-forms", "Username, password, otp and other auth forms.", "basic-flow", false,false); + flowBuilder6.addAuthenticationExecutionWithAuthenticator("auth-username-form", "REQUIRED", 10, false); + flowBuilder6.addAuthenticationExecutionWithAuthenticator("auth-password-form", "REQUIRED" , 20, false); + + // flow for passkeys-username + AuthenticationFlowConfigBuilder flowBuilder7 = builder + .addAuthenticationFlow("passkeys-username", "passkeys username", "basic-flow", true, false); + flowBuilder7.addAuthenticationExecutionWithAuthenticator("auth-cookie", "ALTERNATIVE", 10, false); + flowBuilder7.addAuthenticationExecutionWithAuthenticator("auth-spnego", "DISABLED", 20, false); + flowBuilder7.addAuthenticationExecutionWithAuthenticator("identity-provider-redirector", "DISABLED", 25, false); + flowBuilder7.addAuthenticationExecutionWithAliasFlow("browser-webauthn-organization", "ALTERNATIVE", 26, false); + flowBuilder7.addAuthenticationExecutionWithAliasFlow("passkeys-username-forms", "ALTERNATIVE", 30, false); + RequiredActionProviderRepresentation actionRep1 = new RequiredActionProviderRepresentation(); actionRep1.setAlias("webauthn-register"); actionRep1.setName("Webauthn Register"); @@ -535,6 +486,13 @@ public abstract class AbstractWebAuthnVirtualTest implements UseVirtualAuthentic builder.addUser(USERNAME).password(PASSWORD).name("WebAuthn", "User") .email("webauthn-user@localhost").emailVerified(true); + + builder.addUser("test-user@localhost") + .enabled(true) + .email("test-user@localhost") + .name("Tom", "Brady") + .password(PASSWORD); + return builder; } } diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java new file mode 100644 index 00000000000..182e8652e75 --- /dev/null +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java @@ -0,0 +1,498 @@ +/* + * Copyright 2025 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.passwordless; + + +import org.keycloak.WebAuthnConstants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.testframework.ui.annotations.InjectPage; +import org.keycloak.testframework.ui.page.LoginUsernamePage; +import org.keycloak.testframework.ui.page.PasswordPage; +import org.keycloak.tests.utils.admin.AdminApiUtil; +import org.keycloak.tests.webauthn.AbstractWebAuthnVirtualTest; +import org.keycloak.tests.webauthn.authenticators.DefaultVirtualAuthOptions; + +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; + +/** + * + * @author rmartinc + */ +public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest { + + @InjectPage + protected LoginUsernamePage loginPage; + + @InjectPage + protected PasswordPage passwordPage; + + @Override + protected void switchExecutionInBrowserFormToPasswordless() { + managedRealm.updateWithCleanup(r -> r.browserFlow("passkeys-username")); + UserRepresentation user = AdminApiUtil.findUserByUsername(managedRealm.admin(), USERNAME); + if (user != null) { + managedRealm.admin().users().delete(user.getId()); + } + } + + @Override + public boolean isPasswordless() { + return true; + } + + @Test + public void webauthnLoginWithDiscoverableKey() { + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + // remove the password, so passkeys are the only credential in the user + final CredentialRepresentation passwordCredRep = userResource().credentials().stream() + .filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType())) + .findAny() + .orElse(null); + Assertions.assertNotNull(passwordCredRep, "User has no password credential"); + userResource().removeCredential(passwordCredRep.getId()); + + events.clear(); + + // the user should be automatically logged in using the discoverable key + oAuthClient.openLoginForm(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + logout(); + } + } + + @Test + public void passwordLoginWithNonDiscoverableKey() { + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); + + // set passwordless policy not specified, key will not be discoverable + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + events.clear(); + + // login should be done manually but webauthn is enabled + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // invalid login first + loginPage.fillLoginWithUsernameOnly("invalid-user"); + loginPage.submit(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(loginPage.getUsernameInputError(), Matchers.is("Invalid username or email.")); + EventAssertion.assertError(events.poll()) + .type(EventType.LOGIN_ERROR) + .isCodeId() + .error(Errors.USER_NOT_FOUND) + .details(Details.USERNAME, "invalid-user"); + + // login OK now + loginPage.fillLoginWithUsernameOnly(USERNAME); + loginPage.submit(); + passwordPage.assertCurrent(); + // Passkeys available on password-form as well. Allows to login only with the passkey of current user + MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is(USERNAME)); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + passwordPage.fillPassword(PASSWORD); + passwordPage.submit(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, USERNAME) + .withoutDetails(Details.CREDENTIAL_TYPE); + } + } + + @Test + public void passwordLoginWithExternalKey() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + events.clear(); + + // open login page, the key is not internal so not opened by default + oAuthClient.openLoginForm(); + + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + logout(); + } + } + + // Test that user is able to authenticate with passkeys even during re-authentication (For example when OIDC parameter prompt=login is used) + @Test + public void webauthnLoginWithDiscoverableKey_reauthentication() { + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + // remove the password, so passkeys are the only credential in the user + final CredentialRepresentation passwordCredRep = userResource().credentials().stream() + .filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType())) + .findAny() + .orElse(null); + Assertions.assertNotNull(passwordCredRep, "User has no password credential"); + userResource().removeCredential(passwordCredRep.getId()); + + events.clear(); + + // the user should be automatically logged in using the discoverable key + oAuthClient.openLoginForm(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + // Re-authentication now with prompt=login. Passkeys login should be possible. + oAuthClient.loginForm() + .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .open(); + + passwordPage.assertCurrent(); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + webAuthnLoginPage.clickAuthenticate(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + logout(); + } + } + + @Test + public void passwordLogin_reauthenticationOfUserWithoutPasskey() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + // Login with password + oAuthClient.openLoginForm(); + + // WebAuthn elements available, user is not yet known. Password not available as on username-form + loginPage.assertCurrent(); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // Login with password. WebAuthn elements not available on password screen as user does not have passkeys + loginPage.fillLoginWithUsernameOnly("test-user@localhost"); + loginPage.submit(); + passwordPage.assertCurrent(); + Assertions.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); + passwordPage.fillPassword(PASSWORD); + passwordPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + events.clear(); + + // Re-authentication now with prompt=login. Passkeys login should not be available on the page as this user does not have passkey + oAuthClient.loginForm() + .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .open(); + + passwordPage.assertCurrent(); + Assertions.assertEquals("Please re-authenticate to continue", passwordPage.getInfoMessage().orElse(null)); + Assertions.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); + + // Incorrect password (password of different user) + passwordPage.fillPassword("incorrect"); + passwordPage.submit(); + MatcherAssert.assertThat(passwordPage.getPasswordError(), Matchers.is("Invalid password.")); + + events.clear(); + + // Login with password + passwordPage.fillPassword(PASSWORD); + passwordPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + UserRepresentation testUser = AdminApiUtil.findUserByUsername(managedRealm.admin(), "test-user@localhost"); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(testUser.getId()) + .isCodeId() + .details(Details.USERNAME, testUser.getUsername()) + .withoutDetails(Details.CREDENTIAL_TYPE, WebAuthnConstants.USER_VERIFICATION_CHECKED); + + logout(); + } + } + + @Test + public void passwordLoginWithExternalKeyAndRememberMeLoginAtUsername() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys and enable remember me + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE) + .setRememberMe(Boolean.TRUE)); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + events.clear(); + + // login should be done manually but webauthn is enabled + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link + loginPage.rememberMe(true); + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + EventAssertion loginEvent = EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.REMEMBER_ME, "true") + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + // clear the session and check remember me is present + managedRealm.admin().deleteSession(loginEvent.getEvent().getSessionId(), false); + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + Assertions.assertEquals(user.getUsername(), loginPage.getUsername()); + Assertions.assertTrue(loginPage.isRememberMe()); + + // uncheck remember me and process normally + loginPage.rememberMe(false); + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") + .withoutDetails(Details.REMEMBER_ME); + } + } + + @Test + public void passwordLoginWithExternalKeyAndRememberMeLoginAtPassword() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys and enable remember me + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE) + .setRememberMe(Boolean.TRUE)); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + events.clear(); + + // login should be done manually but webauthn is enabled + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link at password + loginPage.fillLoginWithUsernameOnly(USERNAME); + loginPage.rememberMe(true); + loginPage.submit(); + + // login at password using webauthn + passwordPage.assertCurrent(); + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + EventAssertion loginEvent = EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, USERNAME) + .details(Details.REMEMBER_ME, "true") + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + // clear the session and check remember me is present + managedRealm.admin().deleteSession(loginEvent.getEvent().getSessionId(), false); + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + Assertions.assertEquals(USERNAME, loginPage.getUsername()); + Assertions.assertTrue(loginPage.isRememberMe()); + + // uncheck remember me and process normally using webauthn at password page + loginPage.fillLoginWithUsernameOnly(USERNAME); + loginPage.rememberMe(false); + loginPage.submit(); + passwordPage.assertCurrent(); + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, USERNAME) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") + .withoutDetails(Details.REMEMBER_ME); + } + } +} diff --git a/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java new file mode 100644 index 00000000000..81eecb2c6fb --- /dev/null +++ b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java @@ -0,0 +1,483 @@ +/* + * Copyright 2025 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.passwordless; + +import org.keycloak.WebAuthnConstants; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.Constants; +import org.keycloak.models.credential.WebAuthnCredentialModel; +import org.keycloak.models.utils.DefaultAuthenticationFlows; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testframework.events.EventAssertion; +import org.keycloak.tests.utils.admin.AdminApiUtil; +import org.keycloak.tests.webauthn.AbstractWebAuthnVirtualTest; +import org.keycloak.tests.webauthn.authenticators.DefaultVirtualAuthOptions; + +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.NoSuchElementException; + +/** + * + * @author rmartinc + */ +public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTest { + + @Override + protected void switchExecutionInBrowserFormToPasswordless() { + managedRealm.updateWithCleanup(r -> r.browserFlow(DefaultAuthenticationFlows.BROWSER_FLOW)); + UserRepresentation user = AdminApiUtil.findUserByUsername(managedRealm.admin(), USERNAME); + if (user != null) { + managedRealm.admin().users().delete(user.getId()); + } + } + + @Override + public boolean isPasswordless() { + return true; + } + + @Test + public void webauthnLoginWithDiscoverableKey() { + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(null) + .webAuthnPolicyPasswordlessUserVerificationRequirement(null) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + events.clear(); + + // the user should be automatically logged in using the discoverable key + oAuthClient.openLoginForm(); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + logout(); + } + } + + @Test + public void passwordLoginWithNonDiscoverableKey() { + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); + + // set passwordless policy not specified, key will not be discoverable + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + events.clear(); + + // login should be done manually but webauthn is enabled + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // invalid login first + loginPage.fillLogin(USERNAME, "invalid-password"); + loginPage.submit(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameInputError(), Matchers.is("Invalid username or password.")); + Assertions.assertTrue(loginPage.getPasswordInputError().isEmpty()); + EventAssertion.assertError(events.poll()) + .type(EventType.LOGIN_ERROR) + .isCodeId() + .userId(user.getId()) + .details(Details.USERNAME, USERNAME) + .error(Errors.INVALID_USER_CREDENTIALS); + + // login OK now + loginPage.fillLogin(USERNAME, PASSWORD); + loginPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, USERNAME) + .withoutDetails(Details.CREDENTIAL_TYPE); + + logout(); + } + } + + @Test + public void passwordLoginWithExternalKey() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + events.clear(); + + // open login page, the key is not internal so not opened by default + oAuthClient.openLoginForm(); + + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + logout(); + } + } + + // Test users is able to authenticate with passkey during re-authentication (for example when OIDC parameter prompt=login is used) + @Test + public void webauthnLoginWithExternalKey_reauthentication() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + events.clear(); + + // open login page, the key is not internal so not opened by default + oAuthClient.openLoginForm(); + + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + // Re-authentication now with prompt=login. Passkeys login should be possible. + oAuthClient.loginForm() + .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .open(); + + loginPage.assertCurrent(); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + Assertions.assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage().orElse(null)); + + // force login using webauthn link + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + logout(); + } + } + + // Test user re-authentication with password when passkeys feature enabled, but passkeys is not enabled for the realm. Passkeys should not be shown during re-authentication + @Test + public void reauthenticationOfUserWithoutPasskey() { + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.FALSE)); + + // Login with password + oAuthClient.openLoginForm(); + + // WebAuthn elements not available + loginPage.assertCurrent(); + Assertions.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); + + // Login with password + loginPage.fillLogin("test-user@localhost", PASSWORD); + loginPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + events.clear(); + + // Re-authentication now with prompt=login. Passkeys login should not be available on the page as this user does not have passkey + oAuthClient.loginForm() + .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .open(); + + loginPage.assertCurrent(); + Assertions.assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage().orElse(null)); + Assertions.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); + + // Login with password + loginPage.fillPassword(PASSWORD); + loginPage.submit(); + + UserRepresentation testUser = AdminApiUtil.findUserByUsername(managedRealm.admin(), "test-user@localhost"); + + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(testUser.getId()) + .isCodeId() + .details(Details.USERNAME, testUser.getUsername()) + .withoutDetails(Details.CREDENTIAL_TYPE, WebAuthnConstants.USER_VERIFICATION_CHECKED); + + logout(); + } + } + + // Test user, which has both passkey and password, is able to re-authenticate with any of those. Also checks that re-authentication works after failed login (incorrect password) + @Test + public void webauthnLoginWithExternalKey_reauthenticationWithPasswordOrPasskey() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); + + checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + // open login page, the key is not internal so not opened by default + oAuthClient.openLoginForm(); + + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + // Re-authentication now with prompt=login. Passkeys login should be possible. + oAuthClient.loginForm() + .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .open(); + + loginPage.assertCurrent(); + Assertions.assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage().orElse(null)); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // incorrect password (password of different user) + loginPage.fillPassword("invalid-password"); + loginPage.submit(); + Assertions.assertEquals("Invalid username or password.", loginPage.getPasswordInputError().orElse(null)); + + // Check that passkeys elements still available for this user + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + events.clear(); + + // re-authenticate using passkey credential + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + // Successful event - passkey login + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + // Re-authenticate again + oAuthClient.loginForm() + .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .open(); + + // incorrect password (password of different user) + loginPage.fillPassword("invalid-password"); + loginPage.submit(); + Assertions.assertEquals("Invalid username or password.", loginPage.getPasswordInputError().orElse(null)); + + events.clear(); + + // re-authenticate using password now + loginPage.fillPassword(PASSWORD); + loginPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + // Succesful event - password login + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .withoutDetails(Details.CREDENTIAL_TYPE, WebAuthnConstants.USER_VERIFICATION_CHECKED); + + logout(); + } + } + + @Test + public void passwordLoginWithExternalKeyAndRememberMe() { + // use a default resident key which is not shown in conditional UI + getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); + + // set passwordless policy for discoverable keys and enable remember me + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE) + .setRememberMe(Boolean.TRUE)); + + registerDefaultUser(); + + UserRepresentation user = userResource().toRepresentation(); + MatcherAssert.assertThat(user, Matchers.notNullValue()); + + logout(); + + events.clear(); + + // login should be done manually but webauthn is enabled + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); + MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); + + // force login using webauthn link + loginPage.rememberMe(true); + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion loginEvent = EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.REMEMBER_ME, "true") + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true"); + + // clear the session and check remember me is present + managedRealm.admin().deleteSession(loginEvent.getEvent().getSessionId(), false); + oAuthClient.openLoginForm(); + loginPage.assertCurrent(); + Assertions.assertEquals(user.getUsername(), loginPage.getUsername()); + Assertions.assertTrue(loginPage.isRememberMe()); + + // uncheck remember me and process normally + loginPage.rememberMe(false); + webAuthnLoginPage.clickAuthenticate(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); + + EventAssertion.assertSuccess(events.poll()) + .type(EventType.LOGIN) + .hasSessionId() + .userId(user.getId()) + .isCodeId() + .details(Details.USERNAME, user.getUsername()) + .details(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .details(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") + .withoutDetails(Details.REMEMBER_ME); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java deleted file mode 100644 index 43b164966de..00000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java +++ /dev/null @@ -1,396 +0,0 @@ -/* - * Copyright 2025 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.passwordless; - -import java.io.Closeable; -import java.io.IOException; -import java.util.List; - -import org.keycloak.WebAuthnConstants; -import org.keycloak.authentication.authenticators.browser.PasswordFormFactory; -import org.keycloak.authentication.authenticators.browser.UsernameFormFactory; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; -import org.keycloak.events.EventType; -import org.keycloak.models.AuthenticationExecutionModel; -import org.keycloak.models.Constants; -import org.keycloak.models.credential.PasswordCredentialModel; -import org.keycloak.models.credential.WebAuthnCredentialModel; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; -import org.keycloak.representations.idm.AuthenticationFlowRepresentation; -import org.keycloak.representations.idm.CredentialRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.AbstractAdminTest; -import org.keycloak.testsuite.Assert; -import org.keycloak.testsuite.admin.AdminApiUtil; -import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; -import org.keycloak.testsuite.util.WaitUtils; -import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; -import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; - -import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; -import org.junit.Test; -import org.openqa.selenium.By; -import org.openqa.selenium.NoSuchElementException; -import org.openqa.selenium.firefox.FirefoxDriver; - -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertEquals; - -/** - * - * @author rmartinc - */ -@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 -public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest { - - @Override - public void addTestRealms(List testRealms) { - RealmRepresentation realmRepresentation = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/webauthn/testrealm-webauthn.json"), RealmRepresentation.class); - - makePasswordlessRequiredActionDefault(realmRepresentation); - switchExecutionInBrowser(realmRepresentation); - - configureTestRealm(realmRepresentation); - testRealms.add(realmRepresentation); - } - - private void switchExecutionInBrowser(RealmRepresentation realm) { - List flows = realm.getAuthenticationFlows(); - MatcherAssert.assertThat(flows, Matchers.notNullValue()); - - AuthenticationFlowRepresentation browserForm = flows.stream() - .filter(f -> f.getAlias().equals("browser-webauthn-forms")) - .findFirst() - .orElse(null); - MatcherAssert.assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, Matchers.notNullValue()); - - flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias())); - - // set first the username form authenticator - AuthenticationExecutionExportRepresentation usernameForm = new AuthenticationExecutionExportRepresentation(); - usernameForm.setAuthenticator(UsernameFormFactory.PROVIDER_ID); - usernameForm.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); - usernameForm.setPriority(10); - usernameForm.setAuthenticatorFlow(false); - usernameForm.setUserSetupAllowed(false); - - // second the password form - AuthenticationExecutionExportRepresentation passwordForm = new AuthenticationExecutionExportRepresentation(); - passwordForm.setAuthenticator(PasswordFormFactory.PROVIDER_ID); - passwordForm.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name()); - passwordForm.setPriority(20); - passwordForm.setAuthenticatorFlow(false); - passwordForm.setUserSetupAllowed(false); - - browserForm.setAuthenticationExecutions(List.of(usernameForm, passwordForm)); - flows.add(browserForm); - - realm.setAuthenticationFlows(flows); - } - - @Override - public boolean isPasswordless() { - return true; - } - - @Test - public void webauthnLoginWithDiscoverableKey() throws IOException { - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - - // remove the password, so passkeys are the only credential in the user - final CredentialRepresentation passwordCredRep = userResource().credentials().stream() - .filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType())) - .findAny() - .orElse(null); - Assert.assertNotNull("User has no password credential", passwordCredRep); - userResource().removeCredential(passwordCredRep.getId()); - - events.clear(); - - // the user should be automatically logged in using the discoverable key - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - logout(); - } - } - - @Test - public void passwordLoginWithNonDiscoverableKey() throws IOException { - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); - - // set passwordless policy not specified, key will not be discoverable - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) - .setWebAuthnPolicyUserVerificationRequirement(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - - events.clear(); - - // login should be done manually but webauthn is enabled - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false)); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // invalid login first - loginPage.loginUsername("invalid-user"); - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(loginPage.getUsernameInputError(), Matchers.is("Invalid username or email.")); - events.expect(EventType.LOGIN_ERROR) - .detail(Details.USERNAME, "invalid-user") - .error(Errors.USER_NOT_FOUND) - .user(Matchers.blankOrNullString()) - .assertEvent(); - - // login OK now - loginPage.loginUsername(USERNAME); - loginPage.assertCurrent(); - // Passkeys available on password-form as well. Allows to login only with the passkey of current user - MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is(USERNAME)); - MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true)); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - loginPage.login(getPassword(USERNAME)); - appPage.assertCurrent(); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, USERNAME) - .detail(Details.CREDENTIAL_TYPE, Matchers.nullValue()) - .assertEvent(); - } - } - - @Test - public void passwordLoginWithExternalKey() throws Exception { - // use a default resident key which is not shown in conditional UI - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - events.clear(); - - // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false)); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // force login using webauthn link - webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - logout(); - } - } - - // Test that user is able to authenticate with passkeys even during re-authentication (For example when OIDC parameter prompt=login is used) - @Test - public void webauthnLoginWithDiscoverableKey_reauthentication() throws IOException { - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - - // remove the password, so passkeys are the only credential in the user - final CredentialRepresentation passwordCredRep = userResource().credentials().stream() - .filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType())) - .findAny() - .orElse(null); - Assert.assertNotNull("User has no password credential", passwordCredRep); - userResource().removeCredential(passwordCredRep.getId()); - - events.clear(); - - // the user should be automatically logged in using the discoverable key - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - // Re-authentication now with prompt=login. Passkeys login should be possible. - oauth.loginForm() - .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) - .open(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true)); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - webAuthnLoginPage.clickAuthenticate(); - - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - logout(); - } - } - - - @Test - public void passwordLogin_reauthenticationOfUserWithoutPasskey() throws Exception { - // use a default resident key which is not shown in conditional UI - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - - // Login with password - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - // WebAuthn elements available, user is not yet known. Password not available as on username-form - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false)); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // Login with password. WebAuthn elements not available on password screen as user does not have passkeys - loginPage.loginUsername("test-user@localhost"); - Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); - loginPage.login(getPassword("test-user@localhost")); - appPage.assertCurrent(); - - events.clear(); - - // Re-authentication now with prompt=login. Passkeys login should not be available on the page as this user does not have passkey - oauth.loginForm() - .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) - .open(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage()); - Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); - - // Incorrect password (password of different user) - loginPage.login(getPassword("john-doh@localhost")); - MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password.")); - - events.clear(); - - // Login with password - loginPage.login(getPassword("test-user@localhost")); - appPage.assertCurrent(); - - UserRepresentation testUser = AdminApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").toRepresentation(); - - events.expectLogin() - .user(testUser.getId()) - .detail(Details.USERNAME, testUser.getUsername()) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue()) - .assertEvent(); - - logout(); - } - } -} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java deleted file mode 100644 index bbed3b410f3..00000000000 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java +++ /dev/null @@ -1,421 +0,0 @@ -/* - * Copyright 2025 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.passwordless; - -import java.io.Closeable; -import java.io.IOException; -import java.util.List; - -import org.keycloak.WebAuthnConstants; -import org.keycloak.authentication.authenticators.browser.UsernamePasswordFormFactory; -import org.keycloak.events.Details; -import org.keycloak.events.Errors; -import org.keycloak.events.EventType; -import org.keycloak.models.Constants; -import org.keycloak.models.credential.WebAuthnCredentialModel; -import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.representations.idm.RealmRepresentation; -import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.AbstractAdminTest; -import org.keycloak.testsuite.admin.AdminApiUtil; -import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; -import org.keycloak.testsuite.pages.SelectOrganizationPage; -import org.keycloak.testsuite.util.WaitUtils; -import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; -import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; - -import org.hamcrest.MatcherAssert; -import org.hamcrest.Matchers; -import org.jboss.arquillian.graphene.page.Page; -import org.junit.Assert; -import org.junit.Test; -import org.openqa.selenium.By; -import org.openqa.selenium.NoSuchElementException; -import org.openqa.selenium.firefox.FirefoxDriver; - -import static org.hamcrest.Matchers.nullValue; -import static org.junit.Assert.assertEquals; - -/** - * - * @author rmartinc - */ -@IgnoreBrowserDriver(FirefoxDriver.class) // See https://github.com/keycloak/keycloak/issues/10368 -public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTest { - - @Page - protected SelectOrganizationPage selectOrganizationPage; - - @Override - public void addTestRealms(List testRealms) { - RealmRepresentation realmRepresentation = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/webauthn/testrealm-webauthn.json"), RealmRepresentation.class); - - makePasswordlessRequiredActionDefault(realmRepresentation); - switchExecutionInBrowserFormToProvider(realmRepresentation, UsernamePasswordFormFactory.PROVIDER_ID); - - configureTestRealm(realmRepresentation); - testRealms.add(realmRepresentation); - } - - @Override - public boolean isPasswordless() { - return true; - } - - @Test - public void webauthnLoginWithDiscoverableKey() throws Exception { - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(null) - .setWebAuthnPolicyUserVerificationRequirement(null) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - events.clear(); - - // the user should be automatically logged in using the discoverable key - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - logout(); - } - } - - @Test - public void passwordLoginWithNonDiscoverableKey() throws IOException { - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.PASSKEYS.getOptions()); - - // set passwordless policy not specified, key will not be discoverable - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) - .setWebAuthnPolicyUserVerificationRequirement(Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - - events.clear(); - - // login should be done manually but webauthn is enabled - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // invalid login first - loginPage.login(USERNAME, "invalid-password"); - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameInputError(), Matchers.is("Invalid username or password.")); - MatcherAssert.assertThat(loginPage.getPasswordInputError(), nullValue()); - events.expect(EventType.LOGIN_ERROR) - .detail(Details.USERNAME, USERNAME) - .error(Errors.INVALID_USER_CREDENTIALS) - .user(user.getId()) - .assertEvent(); - - // login OK now - loginPage.login(USERNAME, getPassword(USERNAME)); - appPage.assertCurrent(); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, USERNAME) - .detail(Details.CREDENTIAL_TYPE, nullValue()) - .assertEvent(); - - logout(); - } - } - - @Test - public void passwordLoginWithExternalKey() throws Exception { - // use a default resident key which is not shown in conditional UI - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = setPasswordlessPolicyForExternalKey()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - events.clear(); - - // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // force login using webauthn link - webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - logout(); - } - } - - - // Test users is able to authenticate with passkey during re-authentication (for example when OIDC parameter prompt=login is used) - @Test - public void webauthnLoginWithExternalKey_reauthentication() throws Exception { - // use a default resident key which is not shown in conditional UI - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = setPasswordlessPolicyForExternalKey()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - events.clear(); - - // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // force login using webauthn link - webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - // Re-authentication now with prompt=login. Passkeys login should be possible. - oauth.loginForm() - .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) - .open(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage()); - - // force login using webauthn link - webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - logout(); - } - } - - - // Test user re-authentication with password when passkeys feature enabled, but passkeys is not enabled for the realm. Passkeys should not be shown during re-authentication - @Test - public void reauthenticationOfUserWithoutPasskey() throws Exception { - // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyPasskeysEnabled(Boolean.FALSE) - .update()) { - - // Login with password - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - // WebAuthn elements not available - loginPage.assertCurrent(); - Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); - - // Login with password - loginPage.login("test-user@localhost", getPassword("test-user@localhost")); - appPage.assertCurrent(); - - events.clear(); - - // Re-authentication now with prompt=login. Passkeys login should not be available on the page as this user does not have passkey - oauth.loginForm() - .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) - .open(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage()); - Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); - - // Login with password - loginPage.login(getPassword("test-user@localhost")); - appPage.assertCurrent(); - - UserRepresentation testUser = AdminApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").toRepresentation(); - - events.expectLogin() - .user(testUser.getId()) - .detail(Details.USERNAME, testUser.getUsername()) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue()) - .assertEvent(); - - logout(); - } - } - - - // Test user, which has both passkey and password, is able to re-authenticate with any of those. Also checks that re-authentication works after failed login (incorrect password) - @Test - public void webauthnLoginWithExternalKey_reauthenticationWithPasswordOrPasskey() throws Exception { - // use a default resident key which is not shown in conditional UI - getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions()); - - // set passwordless policy for discoverable keys - try (Closeable c = setPasswordlessPolicyForExternalKey()) { - - checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); - - registerDefaultUser(); - - UserRepresentation user = userResource().toRepresentation(); - MatcherAssert.assertThat(user, Matchers.notNullValue()); - - logout(); - - // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // force login using webauthn link - webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - - // Re-authentication now with prompt=login. Passkeys login should be possible. - oauth.loginForm() - .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) - .open(); - WaitUtils.waitForPageToLoad(); - - loginPage.assertCurrent(); - assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage()); - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - // incorrect password (password of different user) - loginPage.login(getPassword("test-user@localhost")); - Assert.assertEquals("Invalid username or password.", loginPage.getInputError()); - - // Check that passkeys elements still available for this user - MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); - - events.clear(); - - // re-authenticate using passkey credential - webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - - // Successful event - passkey login - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); - - // Re-authenticate again - oauth.loginForm() - .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) - .open(); - WaitUtils.waitForPageToLoad(); - - // incorrect password (password of different user) - loginPage.login(getPassword("test-user@localhost")); - Assert.assertEquals("Invalid username or password.", loginPage.getInputError()); - - events.clear(); - - // re-authenticate using password now - loginPage.login(getPassword(USERNAME)); - appPage.assertCurrent(); - - // Succesful event - password login - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue()) - .assertEvent(); - - logout(); - } - } - - private Closeable setPasswordlessPolicyForExternalKey() { - return getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update(); - } - -} diff --git a/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js b/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js index fb818adf5ec..a5f3adcb763 100644 --- a/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js +++ b/themes/src/main/resources/theme/base/login/resources/js/webauthnAuthenticate.js @@ -91,6 +91,14 @@ export function returnSuccess(result) { if (result.response.userHandle) { document.getElementById("userHandle").value = base64url.stringify(new Uint8Array(result.response.userHandle), { pad: false }); } + const rememberMe = document.getElementById("rememberMe"); + if (rememberMe) { + const rememberMeInput = document.createElement("input"); + rememberMeInput.type = "hidden"; + rememberMeInput.name = "rememberMe"; + rememberMeInput.value = rememberMe.checked ? "on" : "off"; + document.getElementById("webauth").appendChild(rememberMeInput); + } document.getElementById("webauth").requestSubmit(); }