mirror of
https://github.com/keycloak/keycloak.git
synced 2026-06-03 13:58:36 -04:00
Pass and use rememberMe option in passkeys authenticators
Closes #45104
This commit is contained in:
parent
072d0d9279
commit
95cdee91aa
15 changed files with 1148 additions and 918 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue