Skip to content

Commit 4895b09

Browse files
committed
Improve webauthn webdriver tests
Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1 parent 55ccbe6 commit 4895b09

File tree

1 file changed

+97
-10
lines changed

1 file changed

+97
-10
lines changed

config/src/integration-test/java/org/springframework/security/config/annotation/configurers/WebAuthnWebDriverTests.java

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.junit.jupiter.api.AfterEach;
3232
import org.junit.jupiter.api.BeforeAll;
3333
import org.junit.jupiter.api.BeforeEach;
34+
import org.junit.jupiter.api.Disabled;
3435
import org.junit.jupiter.api.Test;
3536
import org.openqa.selenium.By;
3637
import org.openqa.selenium.WebDriverException;
@@ -55,6 +56,7 @@
5556
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
5657
import org.springframework.security.web.FilterChainProxy;
5758
import org.springframework.security.web.SecurityFilterChain;
59+
import org.springframework.util.StringUtils;
5860
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
5961
import org.springframework.web.filter.DelegatingFilterProxy;
6062
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@@ -67,7 +69,7 @@
6769
*
6870
* @author Daniel Garnier-Moiroux
6971
*/
70-
@org.junit.jupiter.api.Disabled
72+
@Disabled
7173
class WebAuthnWebDriverTests {
7274

7375
private String baseUrl;
@@ -82,6 +84,8 @@ class WebAuthnWebDriverTests {
8284

8385
private static final String PASSWORD = "password";
8486

87+
private String authenticatorId = null;
88+
8589
@BeforeAll
8690
static void startChromeDriverService() throws Exception {
8791
driverService = new ChromeDriverService.Builder().usingAnyFreePort().build();
@@ -144,7 +148,7 @@ void cleanupDriver() {
144148
@Test
145149
void loginWhenNoValidAuthenticatorCredentialsThenRejects() {
146150
createVirtualAuthenticator(true);
147-
this.driver.get(this.baseUrl);
151+
this.getAndWait("/", "/login");
148152
this.driver.findElement(signinWithPasskeyButton()).click();
149153
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?error"));
150154
}
@@ -153,7 +157,7 @@ void loginWhenNoValidAuthenticatorCredentialsThenRejects() {
153157
void registerWhenNoLabelThenRejects() {
154158
login();
155159

156-
this.driver.get(this.baseUrl + "/webauthn/register");
160+
this.getAndWait("/webauthn/register");
157161

158162
this.driver.findElement(registerPasskeyButton()).click();
159163
assertHasAlertStartingWith("error", "Error: Passkey Label is required");
@@ -163,7 +167,7 @@ void registerWhenNoLabelThenRejects() {
163167
void registerWhenAuthenticatorNoUserVerificationThenRejects() {
164168
createVirtualAuthenticator(false);
165169
login();
166-
this.driver.get(this.baseUrl + "/webauthn/register");
170+
this.getAndWait("/webauthn/register");
167171
this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator");
168172
this.driver.findElement(registerPasskeyButton()).click();
169173

@@ -178,7 +182,8 @@ void registerWhenAuthenticatorNoUserVerificationThenRejects() {
178182
* <li>Step 1: Log in with username / password</li>
179183
* <li>Step 2: Register a credential from the virtual authenticator</li>
180184
* <li>Step 3: Log out</li>
181-
* <li>Step 4: Log in with the authenticator</li>
185+
* <li>Step 4: Log in with the authenticator (no allowCredentials)</li>
186+
* <li>Step 5: Log in again with the same authenticator (with allowCredentials)</li>
182187
* </ul>
183188
*/
184189
@Test
@@ -190,7 +195,7 @@ void loginWhenAuthenticatorRegisteredThenSuccess() {
190195
login();
191196

192197
// Step 2: register a credential from the virtual authenticator
193-
this.driver.get(this.baseUrl + "/webauthn/register");
198+
this.getAndWait("/webauthn/register");
194199
this.driver.findElement(passkeyLabel()).sendKeys("Virtual authenticator");
195200
this.driver.findElement(registerPasskeyButton()).click();
196201

@@ -212,9 +217,58 @@ void loginWhenAuthenticatorRegisteredThenSuccess() {
212217
logout();
213218

214219
// Step 4: log in with the virtual authenticator
215-
this.driver.get(this.baseUrl + "/webauthn/register");
220+
this.getAndWait("/webauthn/register", "/login");
216221
this.driver.findElement(signinWithPasskeyButton()).click();
217222
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?continue"));
223+
224+
// Step 5: authenticate while being already logged in
225+
// This simulates some use-cases with MFA. Since the user is already logged in,
226+
// the "allowCredentials" property is populated
227+
this.getAndWait("/login");
228+
this.driver.findElement(signinWithPasskeyButton()).click();
229+
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/"));
230+
}
231+
232+
@Test
233+
void registerWhenAuthenticatorAlreadyRegisteredThenRejects() {
234+
createVirtualAuthenticator(true);
235+
login();
236+
registerAuthenticator("Virtual authenticator");
237+
238+
// Cannot re-register the same authenticator because excludeCredentials
239+
// is not empty and contains the given authenticator
240+
this.driver.findElement(passkeyLabel()).sendKeys("Same authenticator");
241+
this.driver.findElement(registerPasskeyButton()).click();
242+
243+
await(() -> assertHasAlertStartingWith("error", "Registration failed"));
244+
}
245+
246+
@Test
247+
void registerSecondAuthenticatorThenSucceeds() {
248+
createVirtualAuthenticator(true);
249+
login();
250+
251+
registerAuthenticator("Virtual authenticator");
252+
this.getAndWait("/webauthn/register");
253+
List<WebElement> passkeyRows = this.driver.findElements(passkeyTableRows());
254+
assertThat(passkeyRows).hasSize(1)
255+
.first()
256+
.extracting((row) -> row.findElement(firstCell()))
257+
.extracting(WebElement::getText)
258+
.isEqualTo("Virtual authenticator");
259+
260+
// Create second authenticator and register
261+
removeAuthenticator();
262+
createVirtualAuthenticator(true);
263+
registerAuthenticator("Second virtual authenticator");
264+
265+
this.getAndWait("/webauthn/register");
266+
267+
passkeyRows = this.driver.findElements(passkeyTableRows());
268+
assertThat(passkeyRows).hasSize(2)
269+
.extracting((row) -> row.findElement(firstCell()))
270+
.extracting(WebElement::getText)
271+
.contains("Second virtual authenticator");
218272
}
219273

220274
/**
@@ -231,11 +285,14 @@ void loginWhenAuthenticatorRegisteredThenSuccess() {
231285
* "https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/">https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/</a>
232286
*/
233287
private void createVirtualAuthenticator(boolean userIsVerified) {
288+
if (StringUtils.hasText(this.authenticatorId)) {
289+
throw new IllegalStateException("Authenticator already exists, please remove it before re-creating one");
290+
}
234291
HasCdp cdpDriver = (HasCdp) this.driver;
235292
cdpDriver.executeCdpCommand("WebAuthn.enable", Map.of("enableUI", false));
236293
// this.driver.addVirtualAuthenticator(createVirtualAuthenticatorOptions());
237294
//@formatter:off
238-
cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
295+
Map<String, Object> cmdResponse = cdpDriver.executeCdpCommand("WebAuthn.addVirtualAuthenticator",
239296
Map.of(
240297
"options",
241298
Map.of(
@@ -248,21 +305,38 @@ private void createVirtualAuthenticator(boolean userIsVerified) {
248305
)
249306
));
250307
//@formatter:on
308+
this.authenticatorId = cmdResponse.get("authenticatorId").toString();
309+
}
310+
311+
private void removeAuthenticator() {
312+
HasCdp cdpDriver = (HasCdp) this.driver;
313+
cdpDriver.executeCdpCommand("WebAuthn.removeVirtualAuthenticator",
314+
Map.of("authenticatorId", this.authenticatorId));
315+
this.authenticatorId = null;
251316
}
252317

253318
private void login() {
254-
this.driver.get(this.baseUrl);
319+
this.getAndWait("/", "/login");
255320
this.driver.findElement(usernameField()).sendKeys(USERNAME);
256321
this.driver.findElement(passwordField()).sendKeys(PASSWORD);
257322
this.driver.findElement(signinWithUsernamePasswordButton()).click();
323+
// Ensure login has completed
324+
await(() -> assertThat(this.driver.getCurrentUrl()).doesNotContain("/login"));
258325
}
259326

260327
private void logout() {
261-
this.driver.get(this.baseUrl + "/logout");
328+
this.getAndWait("/logout");
262329
this.driver.findElement(logoutButton()).click();
263330
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/login?logout"));
264331
}
265332

333+
private void registerAuthenticator(String passkeyName) {
334+
this.getAndWait("/webauthn/register");
335+
this.driver.findElement(passkeyLabel()).sendKeys(passkeyName);
336+
this.driver.findElement(registerPasskeyButton()).click();
337+
await(() -> assertThat(this.driver.getCurrentUrl()).endsWith("/webauthn/register?success"));
338+
}
339+
266340
private AbstractStringAssert<?> assertHasAlertStartingWith(String alertType, String alertMessage) {
267341
WebElement alert = this.driver.findElement(new By.ById(alertType));
268342
assertThat(alert.isDisplayed())
@@ -289,6 +363,15 @@ private void await(Supplier<AbstractAssert<?, ?>> assertion) {
289363
});
290364
}
291365

366+
private void getAndWait(String endpoint) {
367+
this.getAndWait(endpoint, endpoint);
368+
}
369+
370+
private void getAndWait(String endpoint, String redirectUrl) {
371+
this.driver.get(this.baseUrl + endpoint);
372+
this.await(() -> assertThat(this.driver.getCurrentUrl()).endsWith(redirectUrl));
373+
}
374+
292375
private static By.ById passkeyLabel() {
293376
return new By.ById("label");
294377
}
@@ -325,6 +408,10 @@ private static By.ByCssSelector logoutButton() {
325408
return new By.ByCssSelector("button");
326409
}
327410

411+
private static By.ByCssSelector deletePasskeyButton() {
412+
return new By.ByCssSelector("table > tbody > tr > button");
413+
}
414+
328415
/**
329416
* The configuration for WebAuthN tests. It accesses the Server's current port, so we
330417
* can configurer WebAuthnConfigurer#allowedOrigin

0 commit comments

Comments
 (0)