3131import org .junit .jupiter .api .AfterEach ;
3232import org .junit .jupiter .api .BeforeAll ;
3333import org .junit .jupiter .api .BeforeEach ;
34+ import org .junit .jupiter .api .Disabled ;
3435import org .junit .jupiter .api .Test ;
3536import org .openqa .selenium .By ;
3637import org .openqa .selenium .WebDriverException ;
5556import org .springframework .security .provisioning .InMemoryUserDetailsManager ;
5657import org .springframework .security .web .FilterChainProxy ;
5758import org .springframework .security .web .SecurityFilterChain ;
59+ import org .springframework .util .StringUtils ;
5860import org .springframework .web .context .support .AnnotationConfigWebApplicationContext ;
5961import org .springframework .web .filter .DelegatingFilterProxy ;
6062import org .springframework .web .servlet .config .annotation .EnableWebMvc ;
6769 *
6870 * @author Daniel Garnier-Moiroux
6971 */
70- @ org . junit . jupiter . api . Disabled
72+ @ Disabled
7173class 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