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