From 9e09ab02a8747679f31510b0cc3ee39ac7357dd6 Mon Sep 17 00:00:00 2001 From: rmartinc Date: Thu, 9 Apr 2026 15:08:03 +0200 Subject: [PATCH 1/3] Pass and use rememberMe option in passkeys authenticators Closes #45104 Signed-off-by: rmartinc --- .../AbstractUsernameFormAuthenticator.java | 12 +--- .../WebAuthnPasswordlessAuthenticator.java | 6 ++ .../util/AuthenticatorUtils.java | 21 +++++++ .../PasskeysUsernameFormTest.java | 55 +++++++++++++++++++ .../PasskeysUsernamePasswordFormTest.java | 50 +++++++++++++++++ .../resources/js/webauthnAuthenticate.js | 8 +++ 6 files changed, 142 insertions(+), 10 deletions(-) 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 db9c8016dcde..719ebf25180c 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.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 @@ private boolean validateUser(AuthenticationFlowContext context, UserModel user, 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 @@ private boolean badPasswordHandler(AuthenticationFlowContext context, UserModel } 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 644e99d32763..091537b14888 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.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 void action(AuthenticationFlowContext context) { 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 36cfc940e660..d7ca3fbd55df 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 @@ 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 static void setupReauthenticationInUsernamePasswordFormError(Authenticati } } + /** + * Process the remember_me input for authentication. If the inputData contains + * the rememberMe attribute to on and the realm is + * configured with the rememberMe option the auth note is added to the + * authentication session, if not 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/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 index 43b164966def..a8ca6d25f084 100644 --- 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 @@ -35,12 +35,14 @@ import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractAdminTest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.AdminApiUtil; import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.WaitUtils; import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; @@ -393,4 +395,57 @@ public void passwordLogin_reauthenticationOfUserWithoutPasskey() throws Exceptio logout(); } } + + @Test + public void passwordLoginWithExternalKeyAndRememberMe() throws IOException { + // 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 + try (Closeable c = getWebAuthnRealmUpdater() + .setWebAuthnPolicyRpEntityName("localhost") + .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) + .update(); + RealmAttributeUpdater realmUpdater = new RealmAttributeUpdater(testRealm()) + .setRememberMe(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()); + + // force login using webauthn link + loginPage.setRememberMe(true); + webAuthnLoginPage.clickAuthenticate(); + appPage.assertCurrent(); + EventRepresentation loginEvent = events.expectLogin() + .user(user.getId()) + .detail(Details.USERNAME, user.getUsername()) + .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, Boolean.TRUE.toString()) + .detail(Details.REMEMBER_ME, Boolean.TRUE.toString()) + .assertEvent(); + + // clear the session and check remember me is present + testRealm().deleteSession(loginEvent.getSessionId(), false); + oauth.openLoginForm(); + WaitUtils.waitForPageToLoad(); + loginPage.assertCurrent(); + Assert.assertEquals(user.getUsername(), loginPage.getUsername()); + Assert.assertTrue(loginPage.isRememberMeChecked()); + } + } } 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 index bbed3b410f30..eaf0ecfb09ac 100644 --- 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 @@ -29,12 +29,14 @@ import org.keycloak.models.Constants; import org.keycloak.models.credential.WebAuthnCredentialModel; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractAdminTest; import org.keycloak.testsuite.admin.AdminApiUtil; import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; import org.keycloak.testsuite.pages.SelectOrganizationPage; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; import org.keycloak.testsuite.util.WaitUtils; import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; @@ -409,6 +411,54 @@ public void webauthnLoginWithExternalKey_reauthenticationWithPasswordOrPasskey() } } + @Test + public void passwordLoginWithExternalKeyAndRememberMe() throws IOException { + // 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 + try (Closeable c = setPasswordlessPolicyForExternalKey(); + RealmAttributeUpdater realmUpdater = new RealmAttributeUpdater(testRealm()) + .setRememberMe(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()); + + // force login using webauthn link + loginPage.setRememberMe(true); + webAuthnLoginPage.clickAuthenticate(); + appPage.assertCurrent(); + EventRepresentation loginEvent = events.expectLogin() + .user(user.getId()) + .detail(Details.USERNAME, user.getUsername()) + .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) + .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, Boolean.TRUE.toString()) + .detail(Details.REMEMBER_ME, Boolean.TRUE.toString()) + .assertEvent(); + + // clear the session and check remember me is present + testRealm().deleteSession(loginEvent.getSessionId(), false); + oauth.openLoginForm(); + WaitUtils.waitForPageToLoad(); + loginPage.assertCurrent(); + Assert.assertEquals(user.getUsername(), loginPage.getUsername()); + Assert.assertTrue(loginPage.isRememberMeChecked()); + } + } + private Closeable setPasswordlessPolicyForExternalKey() { return getWebAuthnRealmUpdater() .setWebAuthnPolicyRpEntityName("localhost") 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 fb818adf5ecc..a5f3adcb7636 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(); } From 725be380c8f8a0f470ad785476c2dc17a5d1eb4c Mon Sep 17 00:00:00 2001 From: rmartinc Date: Fri, 10 Apr 2026 11:21:21 +0200 Subject: [PATCH 2/3] Move affected passkeys tests to the new TS Closes #45104 Signed-off-by: rmartinc --- .../tests}/webauthn/passwordless/PasskeysUsernameFormTest.java | 0 .../webauthn/passwordless/PasskeysUsernamePasswordFormTest.java | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite => tests/webauthn/src/test/java/org/keycloak/tests}/webauthn/passwordless/PasskeysUsernameFormTest.java (100%) rename {testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite => tests/webauthn/src/test/java/org/keycloak/tests}/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java (100%) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java similarity index 100% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernameFormTest.java rename to tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernameFormTest.java diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java b/tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java similarity index 100% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java rename to tests/webauthn/src/test/java/org/keycloak/tests/webauthn/passwordless/PasskeysUsernamePasswordFormTest.java From 6f30ac1fa1544ea8096b894ca9e1998d9da509ce Mon Sep 17 00:00:00 2001 From: rmartinc Date: Fri, 10 Apr 2026 11:22:34 +0200 Subject: [PATCH 3/3] Migrate affected tests to the new TS Closes #45104 Signed-off-by: rmartinc --- .../util/AuthenticatorUtils.java | 8 +- .../testframework/events/EventAssertion.java | 8 + .../realm/RealmConfigBuilder.java | 5 + .../ui/page/AbstractLoginPage.java | 23 + .../testframework/ui/page/LoginPage.java | 22 +- .../ui/page/LoginUsernamePage.java | 35 ++ .../tests/oauth/RefreshTokenTest.java | 2 +- .../webauthn/AbstractWebAuthnVirtualTest.java | 126 ++--- .../PasskeysUsernameFormTest.java | 469 ++++++++++-------- .../PasskeysUsernamePasswordFormTest.java | 392 ++++++++------- 10 files changed, 594 insertions(+), 496 deletions(-) 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 d7ca3fbd55df..4e2d6802e38e 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 @@ -142,10 +142,10 @@ public static void setupReauthenticationInUsernamePasswordFormError(Authenticati } /** - * Process the remember_me input for authentication. If the inputData contains - * the rememberMe attribute to on and the realm is - * configured with the rememberMe option the auth note is added to the - * authentication session, if not the note is removed from the auth session. + * 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 */ 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 b7a12de5332a..d1181e54cb0a 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 EventAssertion withoutDetails(String... keys) { 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 0c27c2a3af1a..187bd28b2559 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 RealmConfigBuilder webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegist 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 49e0143c6c80..3943ef5546a2 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 String getAttemptedUsername() { } } + 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 d5bcd87f7118..fe18ea5fd94c 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 void fillLogin(String username, String password) { passwordInput.sendKeys(password); } + public void fillPassword(String password) { + passwordInput.clear(); + passwordInput.sendKeys(password); + } + public void submit() { submitButton.click(); } @@ -86,6 +93,10 @@ public String getUsername() { return usernameInput.getAttribute("value"); } + public String getUsernameAutocomplete() { + return usernameInput.getDomAttribute("autocomplete"); + } + public void clearUsernameInput() { usernameInput.clear(); } @@ -98,12 +109,11 @@ public String getUsernameInputError() { } } - 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 0dc5ba5873ea..2f9e48f26426 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 @@ 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 void submit() { 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 1550def7b238..1266ef47503a 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 void testTimeoutWhenReUsingPreviousAuthenticationSession() { 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 144ae146347d..1ebc56b31705 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.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 @@ 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 @@ protected Credential getDefaultNonResidentKeyCredential() { 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); - } - - /** - * 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); + authRes.updateRequiredAction(webAuthnPasswordlessProvider.getAlias(), webAuthnPasswordlessProvider); - 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 RealmConfigBuilder configure(RealmConfigBuilder builder) { 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 RealmConfigBuilder configure(RealmConfigBuilder builder) { 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 index a8ca6d25f084..182e8652e756 100644 --- 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 @@ -15,98 +15,53 @@ * limitations under the License. */ -package org.keycloak.testsuite.webauthn.passwordless; +package org.keycloak.tests.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.EventRepresentation; -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.updaters.RealmAttributeUpdater; -import org.keycloak.testsuite.util.WaitUtils; -import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; -import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; +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.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.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); + @InjectPage + protected LoginUsernamePage loginPage; - configureTestRealm(realmRepresentation); - testRealms.add(realmRepresentation); - } + @InjectPage + protected PasswordPage passwordPage; - 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 + 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 @@ -115,17 +70,15 @@ public boolean isPasswordless() { } @Test - public void webauthnLoginWithDiscoverableKey() throws IOException { + public void webauthnLoginWithDiscoverableKey() { 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()) { - + { + 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(); @@ -140,39 +93,40 @@ public void webauthnLoginWithDiscoverableKey() throws IOException { .filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType())) .findAny() .orElse(null); - Assert.assertNotNull("User has no password credential", passwordCredRep); + 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 - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); + 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() throws IOException { + public void passwordLoginWithNonDiscoverableKey() { 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()) { + { + 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(); @@ -183,53 +137,56 @@ public void passwordLoginWithNonDiscoverableKey() throws IOException { events.clear(); // login should be done manually but webauthn is enabled - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); 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.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.")); - events.expect(EventType.LOGIN_ERROR) - .detail(Details.USERNAME, "invalid-user") + EventAssertion.assertError(events.poll()) + .type(EventType.LOGIN_ERROR) + .isCodeId() .error(Errors.USER_NOT_FOUND) - .user(Matchers.blankOrNullString()) - .assertEvent(); + .details(Details.USERNAME, "invalid-user"); // login OK now - loginPage.loginUsername(USERNAME); - loginPage.assertCurrent(); + 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(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(); + 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() throws Exception { + 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 - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { + { + 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); @@ -242,40 +199,40 @@ public void passwordLoginWithExternalKey() throws Exception { events.clear(); // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); 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(); + 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() throws IOException { + public void webauthnLoginWithDiscoverableKey_reauthentication() { 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()) { + { + 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); @@ -291,126 +248,127 @@ public void webauthnLoginWithDiscoverableKey_reauthentication() throws IOExcepti .filter(cred -> PasswordCredentialModel.TYPE.equals(cred.getType())) .findAny() .orElse(null); - Assert.assertNotNull("User has no password credential", passwordCredRep); + 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 - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); + 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. - oauth.loginForm() + oAuthClient.loginForm() .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) .open(); - WaitUtils.waitForPageToLoad(); - loginPage.assertCurrent(); - MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true)); + passwordPage.assertCurrent(); MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue()); webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); + 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() throws Exception { + 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 - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update()) { + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) + .webAuthnPolicyPasswordlessUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); // Login with password - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); // 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(); + 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 - oauth.loginForm() + oAuthClient.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']"))); + 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) - loginPage.login(getPassword("john-doh@localhost")); - MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password.")); + passwordPage.fillPassword("incorrect"); + passwordPage.submit(); + MatcherAssert.assertThat(passwordPage.getPasswordError(), Matchers.is("Invalid password.")); events.clear(); // Login with password - loginPage.login(getPassword("test-user@localhost")); - appPage.assertCurrent(); + passwordPage.fillPassword(PASSWORD); + passwordPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - UserRepresentation testUser = AdminApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").toRepresentation(); + UserRepresentation testUser = AdminApiUtil.findUserByUsername(managedRealm.admin(), "test-user@localhost"); - events.expectLogin() - .user(testUser.getId()) - .detail(Details.USERNAME, testUser.getUsername()) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue()) - .assertEvent(); + 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 passwordLoginWithExternalKeyAndRememberMe() throws IOException { + 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 - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update(); - RealmAttributeUpdater realmUpdater = new RealmAttributeUpdater(testRealm()) - .setRememberMe(Boolean.TRUE) - .update()) { + { + 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(); @@ -421,31 +379,120 @@ public void passwordLoginWithExternalKeyAndRememberMe() throws IOException { events.clear(); // login should be done manually but webauthn is enabled - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + 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.setRememberMe(true); + 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(); - appPage.assertCurrent(); - EventRepresentation loginEvent = events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, Boolean.TRUE.toString()) - .detail(Details.REMEMBER_ME, Boolean.TRUE.toString()) - .assertEvent(); + 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 - testRealm().deleteSession(loginEvent.getSessionId(), false); - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + managedRealm.admin().deleteSession(loginEvent.getEvent().getSessionId(), false); + oAuthClient.openLoginForm(); loginPage.assertCurrent(); - Assert.assertEquals(user.getUsername(), loginPage.getUsername()); - Assert.assertTrue(loginPage.isRememberMeChecked()); + 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 index eaf0ecfb09ac..81eecb2c6fb2 100644 --- 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 @@ -15,63 +15,42 @@ * limitations under the License. */ -package org.keycloak.testsuite.webauthn.passwordless; - -import java.io.Closeable; -import java.io.IOException; -import java.util.List; +package org.keycloak.tests.webauthn.passwordless; 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.models.utils.DefaultAuthenticationFlows; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.representations.idm.EventRepresentation; -import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.UserRepresentation; -import org.keycloak.testsuite.AbstractAdminTest; -import org.keycloak.testsuite.admin.AdminApiUtil; -import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver; -import org.keycloak.testsuite.pages.SelectOrganizationPage; -import org.keycloak.testsuite.updaters.RealmAttributeUpdater; -import org.keycloak.testsuite.util.WaitUtils; -import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest; -import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions; +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.jboss.arquillian.graphene.page.Page; -import org.junit.Assert; -import org.junit.Test; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.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); + 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 @@ -80,16 +59,15 @@ public boolean isPasswordless() { } @Test - public void webauthnLoginWithDiscoverableKey() throws Exception { + public void webauthnLoginWithDiscoverableKey() { 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()) { + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessRpEntityName("localhost") + .webAuthnPolicyPasswordlessRequireResidentKey(null) + .webAuthnPolicyPasswordlessUserVerificationRequirement(null) + .webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.TRUE)); checkWebAuthnConfiguration(Constants.WEBAUTHN_POLICY_OPTION_YES, Constants.WEBAUTHN_POLICY_OPTION_REQUIRED); @@ -102,33 +80,34 @@ public void webauthnLoginWithDiscoverableKey() throws Exception { events.clear(); // the user should be automatically logged in using the discoverable key - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); + 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() throws IOException { + public void passwordLoginWithNonDiscoverableKey() { 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()) { + { + 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(); @@ -139,43 +118,52 @@ public void passwordLoginWithNonDiscoverableKey() throws IOException { events.clear(); // login should be done manually but webauthn is enabled - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + 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.login(USERNAME, "invalid-password"); + loginPage.fillLogin(USERNAME, "invalid-password"); + loginPage.submit(); 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(); + 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.login(USERNAME, getPassword(USERNAME)); - appPage.assertCurrent(); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, USERNAME) - .detail(Details.CREDENTIAL_TYPE, nullValue()) - .assertEvent(); + 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() throws Exception { + 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 - try (Closeable c = setPasswordlessPolicyForExternalKey()) { + { + 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); @@ -188,8 +176,7 @@ public void passwordLoginWithExternalKey() throws Exception { events.clear(); // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); loginPage.assertCurrent(); MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); @@ -197,27 +184,33 @@ public void passwordLoginWithExternalKey() throws Exception { // 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(); + 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() throws Exception { + 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 - try (Closeable c = setPasswordlessPolicyForExternalKey()) { + { + 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); @@ -230,8 +223,7 @@ public void webauthnLoginWithExternalKey_reauthentication() throws Exception { events.clear(); // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); loginPage.assertCurrent(); MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); @@ -239,98 +231,105 @@ public void webauthnLoginWithExternalKey_reauthentication() throws Exception { // force login using webauthn link webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); + 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. - oauth.loginForm() + oAuthClient.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()); + Assertions.assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage().orElse(null)); // force login using webauthn link webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, "true") - .assertEvent(); + 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() throws Exception { + public void reauthenticationOfUserWithoutPasskey() { // set passwordless policy for discoverable keys - try (Closeable c = getWebAuthnRealmUpdater() - .setWebAuthnPolicyPasskeysEnabled(Boolean.FALSE) - .update()) { + { + managedRealm.updateWithCleanup(r -> r.webAuthnPolicyPasswordlessPasskeysEnabled(Boolean.FALSE)); // Login with password - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); // WebAuthn elements not available loginPage.assertCurrent(); - Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); + Assertions.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']"))); // Login with password - loginPage.login("test-user@localhost", getPassword("test-user@localhost")); - appPage.assertCurrent(); + 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 - oauth.loginForm() + oAuthClient.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']"))); + 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.login(getPassword("test-user@localhost")); - appPage.assertCurrent(); + loginPage.fillPassword(PASSWORD); + loginPage.submit(); + + UserRepresentation testUser = AdminApiUtil.findUserByUsername(managedRealm.admin(), "test-user@localhost"); - UserRepresentation testUser = AdminApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").toRepresentation(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); - events.expectLogin() - .user(testUser.getId()) - .detail(Details.USERNAME, testUser.getUsername()) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue()) - .assertEvent(); + 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() throws Exception { + 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 - try (Closeable c = setPasswordlessPolicyForExternalKey()) { + { + 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); @@ -342,8 +341,7 @@ public void webauthnLoginWithExternalKey_reauthenticationWithPasswordOrPasskey() logout(); // open login page, the key is not internal so not opened by default - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + oAuthClient.openLoginForm(); loginPage.assertCurrent(); MatcherAssert.assertThat(loginPage.getUsernameAutocomplete(), Matchers.is("username webauthn")); @@ -351,21 +349,21 @@ public void webauthnLoginWithExternalKey_reauthenticationWithPasswordOrPasskey() // force login using webauthn link webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); // Re-authentication now with prompt=login. Passkeys login should be possible. - oauth.loginForm() + oAuthClient.loginForm() .prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN) .open(); - WaitUtils.waitForPageToLoad(); loginPage.assertCurrent(); - assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage()); + 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.login(getPassword("test-user@localhost")); - Assert.assertEquals("Invalid username or password.", loginPage.getInputError()); + 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()); @@ -374,53 +372,61 @@ public void webauthnLoginWithExternalKey_reauthenticationWithPasswordOrPasskey() // re-authenticate using passkey credential webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); // 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(); + 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 - oauth.loginForm() + oAuthClient.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()); + loginPage.fillPassword("invalid-password"); + loginPage.submit(); + Assertions.assertEquals("Invalid username or password.", loginPage.getPasswordInputError().orElse(null)); events.clear(); // re-authenticate using password now - loginPage.login(getPassword(USERNAME)); - appPage.assertCurrent(); + loginPage.fillPassword(PASSWORD); + loginPage.submit(); + Assertions.assertNotNull(oAuthClient.parseLoginResponse().getCode()); // Succesful event - password login - events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue()) - .assertEvent(); + 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() throws IOException { + 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 - try (Closeable c = setPasswordlessPolicyForExternalKey(); - RealmAttributeUpdater realmUpdater = new RealmAttributeUpdater(testRealm()) - .setRememberMe(Boolean.TRUE) - .update()) { + { + 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(); @@ -431,41 +437,47 @@ public void passwordLoginWithExternalKeyAndRememberMe() throws IOException { events.clear(); // login should be done manually but webauthn is enabled - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + 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.setRememberMe(true); + loginPage.rememberMe(true); webAuthnLoginPage.clickAuthenticate(); - appPage.assertCurrent(); - EventRepresentation loginEvent = events.expectLogin() - .user(user.getId()) - .detail(Details.USERNAME, user.getUsername()) - .detail(Details.CREDENTIAL_TYPE, WebAuthnCredentialModel.TYPE_PASSWORDLESS) - .detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, Boolean.TRUE.toString()) - .detail(Details.REMEMBER_ME, Boolean.TRUE.toString()) - .assertEvent(); + 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 - testRealm().deleteSession(loginEvent.getSessionId(), false); - oauth.openLoginForm(); - WaitUtils.waitForPageToLoad(); + managedRealm.admin().deleteSession(loginEvent.getEvent().getSessionId(), false); + oAuthClient.openLoginForm(); loginPage.assertCurrent(); - Assert.assertEquals(user.getUsername(), loginPage.getUsername()); - Assert.assertTrue(loginPage.isRememberMeChecked()); - } - } + Assertions.assertEquals(user.getUsername(), loginPage.getUsername()); + Assertions.assertTrue(loginPage.isRememberMe()); - private Closeable setPasswordlessPolicyForExternalKey() { - return getWebAuthnRealmUpdater() - .setWebAuthnPolicyRpEntityName("localhost") - .setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES) - .setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED) - .setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE) - .update(); + // 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); + } } - }