Pass and use rememberMe option in passkeys authenticators

Closes #45104
This commit is contained in:
Ricardo Martin 2026-04-15 15:28:56 +02:00 committed by GitHub
parent 072d0d9279
commit 95cdee91aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1148 additions and 918 deletions

View file

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

View file

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

View file

@ -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 <em>rememberMe</em> input for authentication. If the inputData contains
* the <em>rememberMe</em> attribute set to <em>on</em> 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<String, String> 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);
}
}
}

View file

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

View file

@ -454,6 +454,11 @@ public class RealmConfigBuilder {
return this;
}
public RealmConfigBuilder webAuthnPolicyPasswordlessPasskeysEnabled(Boolean enabled) {
rep.setWebAuthnPolicyPasswordlessPasskeysEnabled(enabled);
return this;
}
public RealmConfigBuilder webAuthnPolicyAcceptableAaguids(List<String> aaguids) {
rep.setWebAuthnPolicyAcceptableAaguids(aaguids);
return this;

View file

@ -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<String> getInfoMessage() {
try {
return Optional.of(loginInfoMessage.getText());
} catch (NoSuchElementException e) {
return Optional.empty();
}
}
public Optional<String> getErrorMessage() {
try {
return Optional.of(loginErrorMessage.getText());
} catch (NoSuchElementException e) {
return Optional.empty();
}
}
}

View file

@ -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<String> getPasswordInputError() {
try {
return loginErrorMessage.getText();
return Optional.of(passwordInputError.getText());
} catch (NoSuchElementException e) {
return null;
return Optional.empty();
}
}
}

View file

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

View file

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

View file

@ -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<AuthenticationFlowRepresentation> flows = realm.getAuthenticationFlows();
assertThat(flows, notNullValue());
AuthenticationFlowRepresentation browserForm = flows.stream()
.filter(f -> f.getAlias().equals("browser-webauthn-forms"))
.findFirst()
.orElse(null);
assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, notNullValue());
flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias()));
// set just one authenticator with the passkeys conditional UI
AuthenticationExecutionExportRepresentation passkeysConditionalUI = new AuthenticationExecutionExportRepresentation();
passkeysConditionalUI.setAuthenticator(providerId);
passkeysConditionalUI.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED.name());
passkeysConditionalUI.setPriority(10);
passkeysConditionalUI.setAuthenticatorFlow(false);
passkeysConditionalUI.setUserSetupAllowed(false);
browserForm.setAuthenticationExecutions(List.of(passkeysConditionalUI));
flows.add(browserForm);
realm.setAuthenticationFlows(flows);
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<AuthenticationFlowRepresentation> flows = realm.getAuthenticationFlows();
assertThat(flows, notNullValue());
AuthenticationFlowRepresentation browserForm = flows.stream()
.filter(f -> f.getAlias().equals("browser-webauthn-forms"))
.findFirst()
.orElse(null);
assertThat("Cannot find 'browser-webauthn-forms' flow", browserForm, notNullValue());
flows.removeIf(f -> f.getAlias().equals(browserForm.getAlias()));
List<AuthenticationExecutionExportRepresentation> browserFormExecutions = browserForm.getAuthenticationExecutions();
assertThat("Flow 'browser-webauthn-forms' doesn't have any executions", browserForm, notNullValue());
AuthenticationExecutionExportRepresentation webAuthn = browserFormExecutions.stream()
.filter(f -> WebAuthnAuthenticatorFactory.PROVIDER_ID.equals(f.getAuthenticator()))
.findFirst()
.orElse(null);
assertThat("Cannot find WebAuthn execution in Browser flow", webAuthn, notNullValue());
browserFormExecutions.removeIf(f -> webAuthn.getAuthenticator().equals(f.getAuthenticator()));
webAuthn.setAuthenticator(WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID);
browserFormExecutions.add(webAuthn);
browserForm.setAuthenticationExecutions(browserFormExecutions);
flows.add(browserForm);
realm.setAuthenticationFlows(flows);
protected void 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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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