diff --git a/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java index b0a035ef6b..baa4476750 100644 --- a/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java +++ b/config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.openqa.selenium.By; import org.openqa.selenium.WebDriverException; @@ -55,6 +56,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.util.StringUtils; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; import org.springframework.web.filter.DelegatingFilterProxy; import org.springframework.web.servlet.config.annotation.EnableWebMvc; @@ -67,7 +69,7 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Daniel Garnier-Moiroux */ -@org.junit.jupiter.api.Disabled +@Disabled class WebAuthnWebDriverTests { private String baseUrl; @@ -82,6 +84,8 @@ class WebAuthnWebDriverTests { private static final String PASSWORD = "password"; + private String authenticatorId = null; + @BeforeAll static void startChromeDriverService() throws Exception { driverService = new ChromeDriverService.Builder().usingAnyFreePort().build(); @@ -144,7 +148,7 @@ class WebAuthnWebDriverTests { @Test void loginWhenNoValidAuthenticatorCredentialsThenRejects() { createVirtualAuthenticator(true); - this.driver.get(this.baseUrl); + this.getAndWait("/", "/login"); this.driver.findElement(signinWithPasskeyButton()).click(); await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error")); } @@ -153,7 +157,7 @@ class WebAuthnWebDriverTests { void registerWhenNoLabelThenRejects() { login(); - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register"); this.driver.findElement(registerPasskeyButton()).click(); assertHasAlertStartingWith("error", "Error: Passkey Label is required"); @@ -163,7 +167,7 @@ class WebAuthnWebDriverTests { void registerWhenAuthenticatorNoUserVerificationThenRejects() { createVirtualAuthenticator(false); login(); - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register"); this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator"); this.driver.findElement(registerPasskeyButton()).click(); @@ -178,7 +182,8 @@ class WebAuthnWebDriverTests { *
  • Step 1: Log in with username / password
  • *
  • Step 2: Register a credential from the virtual authenticator
  • *
  • Step 3: Log out
  • - *
  • Step 4: Log in with the authenticator
  • + *
  • Step 4: Log in with the authenticator (no allowCredentials)
  • + *
  • Step 5: Log in again with the same authenticator (with allowCredentials)
  • * */ @Test @@ -190,7 +195,7 @@ class WebAuthnWebDriverTests { login(); // Step 2: register a credential from the virtual authenticator - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register"); this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator"); this.driver.findElement(registerPasskeyButton()).click(); @@ -212,9 +217,58 @@ class WebAuthnWebDriverTests { logout(); // Step 4: log in with the virtual authenticator - this.driver.get(this.baseUrl + "/webauthn/register"); + this.getAndWait("/webauthn/register", "/login"); this.driver.findElement(signinWithPasskeyButton()).click(); await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue")); + + // Step 5: authenticate while being already logged in + // This simulates some use-cases with MFA. Since the user is already logged in, + // the "allowCredentials" property is populated + this.getAndWait("/login"); + this.driver.findElement(signinWithPasskeyButton()).click(); + await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/")); + } + + @Test + void registerWhenAuthenticatorAlreadyRegisteredThenRejects() { + createVirtualAuthenticator(true); + login(); + registerAuthenticator("Virtual authenticator"); + + // Cannot re-register the same authenticator because excludeCredentials + // is not empty and contains the given authenticator + this.driver.findElement(passkeyLabel()).sendKeys("Same authenticator"); + this.driver.findElement(registerPasskeyButton()).click(); + + await(() -> assertHasAlertStartingWith("error", "Registration failed")); + } + + @Test + void registerSecondAuthenticatorThenSucceeds() { + createVirtualAuthenticator(true); + login(); + + registerAuthenticator("Virtual authenticator"); + this.getAndWait("/webauthn/register"); + List passkeyRows = this.driver.findElements(passkeyTableRows()); + assertThat(passkeyRows).hasSize(1) + .first() + .extracting((row) -> row.findElement(firstCell())) + .extracting(WebElement::getText) + .isEqualTo("Virtual authenticator"); + + // Create second authenticator and register + removeAuthenticator(); + createVirtualAuthenticator(true); + registerAuthenticator("Second virtual authenticator"); + + this.getAndWait("/webauthn/register"); + + passkeyRows = this.driver.findElements(passkeyTableRows()); + assertThat(passkeyRows).hasSize(2) + .extracting((row) -> row.findElement(firstCell())) + .extracting(WebElement::getText) + .contains("Second virtual authenticator"); } /** @@ -231,11 +285,14 @@ class WebAuthnWebDriverTests { * "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/ */ private void createVirtualAuthenticator(boolean userIsVerified) { + if (StringUtils.hasText(this.authenticatorId)) { + throw new IllegalStateException("Authenticator already exists, please remove it before re-creating one"); + } HasCdp cdpDriver = (HasCdp) this.driver; cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false)); // this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions()); //@formatter:off - cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator", + Map cmdResponse = cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator", Map.of( "options", Map.of( @@ -248,21 +305,38 @@ class WebAuthnWebDriverTests { ) )); //@formatter:on + this.authenticatorId = cmdResponse.get("authenticatorId").toString(); + } + + private void removeAuthenticator() { + HasCdp cdpDriver = (HasCdp) this.driver; + cdpDriver.executeCdpCommand("WebAuthn.removeVirtualAuthenticator", + Map.of("authenticatorId", this.authenticatorId)); + this.authenticatorId = null; } private void login() { - this.driver.get(this.baseUrl); + this.getAndWait("/", "/login"); this.driver.findElement(usernameField()).sendKeys(USERNAME); this.driver.findElement(passwordField()).sendKeys(PASSWORD); this.driver.findElement(signinWithUsernamePasswordButton()).click(); + // Ensure login has completed + await(() -> assertThat(this.driver.getCurrentUrl()).doesNotContain("/login")); } private void logout() { - this.driver.get(this.baseUrl + "/logout"); + this.getAndWait("/logout"); this.driver.findElement(logoutButton()).click(); await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout")); } + private void registerAuthenticator(String passkeyName) { + this.getAndWait("/webauthn/register"); + this.driver.findElement(passkeyLabel()).sendKeys(passkeyName); + this.driver.findElement(registerPasskeyButton()).click(); + await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?success")); + } + private AbstractStringAssert assertHasAlertStartingWith(String alertType, String alertMessage) { WebElement alert = this.driver.findElement(new By.ById(alertType)); assertThat(alert.isDisplayed()) @@ -289,6 +363,15 @@ class WebAuthnWebDriverTests { }); } + private void getAndWait(String endpoint) { + this.getAndWait(endpoint, endpoint); + } + + private void getAndWait(String endpoint, String redirectUrl) { + this.driver.get(this.baseUrl + endpoint); + this.await(() -> assertThat(this.driver.getCurrentUrl()).endsWith(redirectUrl)); + } + private static By.ById passkeyLabel() { return new By.ById("label"); } @@ -325,6 +408,10 @@ class WebAuthnWebDriverTests { return new By.ByCssSelector("button"); } + private static By.ByCssSelector deletePasskeyButton() { + return new By.ByCssSelector("table > tbody > tr > button"); + } + /** * The configuration for WebAuthN tests. It accesses the Server's current port, so we * can configurer WebAuthnConfigurer#allowedOrigin