[java-idp-plugin-webauthn] branch main updated: Add registration flow tests

Phil Smart philip.smart at jisc.ac.uk
Fri Aug 9 15:05:31 UTC 2024


This is an automated email from the git hooks/post-receive script.

philsmart pushed a commit to branch main
in repository java-idp-plugin-webauthn.

View the commit online:
http://git.shibboleth.net/view/?p=java-idp-plugin-webauthn.git;a=commit;h=f2c1fcedff354d4f639b09c7aa1384824acdf388

The following commit(s) were added to refs/heads/main by this push:
     new f2c1fce  Add registration flow tests
f2c1fce is described below

commit f2c1fcedff354d4f639b09c7aa1384824acdf388
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Aug 9 16:05:28 2024 +0100

    Add registration flow tests
---
 .../webauthn/flow/AbstractWebAuthnFlowTest.java    |  53 +++++
 .../authn/webauthn/flow/TestRegistrationFlow.java  | 218 +++++++++++++++++++++
 ...nRegistrationApplicationContextInitializer.java |  56 ++++++
 .../webauthn/storage/impl/MockAuthenticator.java   |   1 +
 4 files changed, 328 insertions(+)

diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
index 709abd1..e6bd672 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/AbstractWebAuthnFlowTest.java
@@ -77,6 +77,7 @@ import net.shibboleth.idp.authn.context.AuthenticationContext;
 import net.shibboleth.idp.authn.context.SubjectCanonicalizationContext;
 import net.shibboleth.idp.authn.principal.UsernamePrincipal;
 import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
 import net.shibboleth.idp.plugin.authn.webauthn.principal.WebAuthnUserIdPrinicpal;
 import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
 import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.IdPStorageServiceCredentialRespository;
@@ -327,6 +328,17 @@ public class AbstractWebAuthnFlowTest extends AbstractFlowTest {
     }
     
 
+    /**
+     * Get the profile request context from the conversation scope.
+     * 
+     * @param flowExec the flow execution
+     * @return the profile request context
+     */
+    protected ProfileRequestContext getProfileRequestContextFromConversation(final FlowExecutionImpl flowExec) {
+        return (ProfileRequestContext) flowExec.getConversationScope().get(ProfileRequestContext.BINDING_KEY);
+    }
+    
+
     /**
      * Get the {@link WebAuthnAuthenticationContext}.
      * 
@@ -336,6 +348,15 @@ public class AbstractWebAuthnFlowTest extends AbstractFlowTest {
         return prc.ensureSubcontext(AuthenticationContext.class).ensureSubcontext(WebAuthnAuthenticationContext.class);
     }
     
+    /**
+     * Get the {@link WebAuthnRegistrationContext}.
+     * 
+     * @return the {@link WebAuthnRegistrationContext}
+     */
+    protected WebAuthnRegistrationContext getWebAuthnRegistrationContext(final ProfileRequestContext prc) {
+        return prc.ensureSubcontext(WebAuthnRegistrationContext.class);
+    }
+    
     /**
      * Create a credential registration attestation response from the mock authenticator.
      * 
@@ -356,6 +377,27 @@ public class AbstractWebAuthnFlowTest extends AbstractFlowTest {
        return attestation;
     }
     
+
+    /**
+     * Create a credential registration attestation response from the mock authenticator.
+     * 
+     * @return the credential registration 
+     * 
+     * @throws Exception on error
+     */
+    protected PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs>
+                createAttestationReponse(final byte[] challenge) throws Exception {
+
+        final String challengeB64 = Base64Support.encodeURLSafe(challenge);
+        final Map<String, String> clientDataCreate = createClientData("webauthn.create", ORIGIN, challengeB64);
+        
+        final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation = 
+                mockAuthenticator.createAuthenticatorAttestationResponse(challengeB64, clientDataCreate, 
+                        Base64Support.decode(USER_HANDLE_B64));
+        
+       return attestation;
+    }
+    
     /**
      * Create a credential authentication assertion response from the mock authenticator.
      * 
@@ -486,6 +528,17 @@ public class AbstractWebAuthnFlowTest extends AbstractFlowTest {
         return prc;
     }
     
+    /**
+     * Build a {@link ProfileRequestContext} by configuring a suitable admin context tree.
+     * 
+     * @return a profile request context.
+     */
+    @Nonnull protected ProfileRequestContext buildAdminProfileRequestContext() {
+        
+        final ProfileRequestContext prc = new ProfileRequestContext();        
+        return prc;
+    }
+    
     /**
      * Copy of {@link FlowExecutorImpl#launchExecution(String, MutableAttributeMap, ExternalContext)} to allow 
      * conversation scope variables to be set before execution.
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java
new file mode 100644
index 0000000..052e87f
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestRegistrationFlow.java
@@ -0,0 +1,218 @@
+/*
+ * 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 net.shibboleth.idp.plugin.authn.webauthn.flow;
+
+import static org.testng.Assert.assertEquals;
+
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.webflow.context.ExternalContextHolder;
+import org.springframework.webflow.core.collection.LocalAttributeMap;
+import org.springframework.webflow.engine.impl.FlowExecutionImpl;
+import org.springframework.webflow.executor.FlowExecutionResult;
+import org.testng.annotations.Test;
+
+import com.yubico.webauthn.data.AuthenticatorAssertionResponse;
+import com.yubico.webauthn.data.ByteArray;
+import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
+import com.yubico.webauthn.data.PublicKeyCredential;
+
+import net.shibboleth.idp.plugin.authn.webauthn.admin.impl.ExtractKeyRemovalInformationFromFormRequest;
+import net.shibboleth.idp.plugin.authn.webauthn.admin.impl.ExtractPublicKeyCredentialAttestationFromFormRequest;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnRegistrationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRegistration;
+import net.shibboleth.shared.collection.Pair;
+
+/**
+ * Flow tests for the passwordless flow.
+ */
+ at ContextConfiguration(
+        locations = {
+                "classpath*:/META-INF/net.shibboleth.idp/postconfig.xml",
+                "classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans.xml", },
+        initializers = {
+                TestWebAuthnEnvironmentApplicationContextInitializer.class,
+                TestWebAuthnRegistrationApplicationContextInitializer.class
+                }
+        )
+/**
+ * Flow tests for the credential registration flow
+ */
+public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{  
+
+    /** Flow ID. */
+    @Nonnull public static final String FLOW_ID = "admin/webauthn-registration";
+    
+    /**
+     * Constructor.
+     *
+     * @param id
+     */
+    protected TestRegistrationFlow(final String id) {
+        super(id);
+    }
+    
+    @SuppressWarnings("null")
+    @Test
+    public void testRegistrationFlow_RemoveCredential() throws Exception {
+        //Register a credential for use and to delete.
+        final CredentialRegistration registration = 
+                createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+        credentialRepo.addRegistrationByUsername(USERNAME, registration);
+        
+        final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext, 
+                new LocalAttributeMap<>());
+        
+        doAuthenticationDance(result, registration);
+        assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
+        
+        // Re-set external context to holder
+        ExternalContextHolder.setExternalContext(externalContext);
+        setHttpFormRequest("POST", Map.of(ExtractKeyRemovalInformationFromFormRequest.DEFAULT_PARAMETER_NAME,
+                registration.getCredentialIdBase64Url()));
+        externalContext.setEventId("deleteKey");
+        result.getSecond().setCurrentState("DisplayWebAuthnRegistrationView");
+        result.getSecond().resume(externalContext);
+        
+        assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
+        // Test is has been removed
+        assertEquals(credentialRepo.getCredentialIdsForUsername(USERNAME).size(),0);
+        
+    }
+    
+    /** 
+     * Check to make sure we have guards in place to prevent the user from deleting a credential from another user. The
+     * credential comes from user input on the UI, so we must ensure that belongs to the authenticated user.
+     * @throws Exception on error
+     */
+    @SuppressWarnings("null")
+    @Test
+    public void testRegistrationFlow_RemoveCredentialFromDifferentUser() throws Exception {
+        //Register a credential for use
+        final CredentialRegistration registration = 
+                createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+        credentialRepo.addRegistrationByUsername(USERNAME, registration);
+        
+        // Register another uses credential and try and delete it
+        final CredentialRegistration registrationAnotherUser = 
+                createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+        credentialRepo.addRegistrationByUsername("another-user", registrationAnotherUser);
+        
+        final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext, 
+                new LocalAttributeMap<>());
+        
+        doAuthenticationDance(result, registration);
+        assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
+        
+        // Re-set external context to holder
+        ExternalContextHolder.setExternalContext(externalContext);
+        setHttpFormRequest("POST", Map.of(ExtractKeyRemovalInformationFromFormRequest.DEFAULT_PARAMETER_NAME,
+                registrationAnotherUser.getCredentialIdBase64Url()));
+        externalContext.setEventId("deleteKey");
+        result.getSecond().setCurrentState("DisplayWebAuthnRegistrationView");
+        result.getSecond().resume(externalContext);
+        
+        assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
+        // Test is has been removed
+        assertEquals(credentialRepo.getCredentialIdsForUsername(USERNAME).size(),1);
+        assertEquals(credentialRepo.getCredentialIdsForUsername("another-user").size(),1);
+        
+    }
+    
+    @SuppressWarnings("null")
+    @Test
+    public void testRegistrationFlow_AddCredential() throws Exception {
+        //Register a credential for use for authentication.
+        final CredentialRegistration registration = 
+                createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+        credentialRepo.addRegistrationByUsername(USERNAME, registration);
+        
+        final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext, 
+                new LocalAttributeMap<>());
+        
+        doAuthenticationDance(result, registration);
+        assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
+        
+        // Re-set external context to holder
+        ExternalContextHolder.setExternalContext(externalContext);
+        
+        final ProfileRequestContext prc = getProfileRequestContextFromConversation(result.getSecond());
+        
+        // Get the challenge that was set into the PublicKeyCredentialRequestOptions. 
+        // otherwise we will end up signing a different one, which will provide its own test.
+        final WebAuthnRegistrationContext authnContext = getWebAuthnRegistrationContext(prc);
+        final ByteArray challenge = authnContext.getPublicKeyCredentialCreationOptions().getChallenge();
+        
+        final var attestationResponse = createAttestationReponse(challenge.getBytes());
+        final String attestationResponseJson = jsonMapper.writeValueAsString(attestationResponse);
+        setHttpFormRequest("POST", Map.of(ExtractPublicKeyCredentialAttestationFromFormRequest.DEFAULT_PARAMETER_NAME,
+                attestationResponseJson, 
+                ExtractPublicKeyCredentialAttestationFromFormRequest.DEFAULT_NICKNAME_FIELD_NAME, "new-cred"));
+        externalContext.setEventId("addKey");
+        result.getSecond().setCurrentState("DisplayWebAuthnRegistrationView");
+        result.getSecond().resume(externalContext);
+        
+        assertCurrentStateEquals("DisplayWebAuthnRegistrationView", result.getSecond());
+        // Test is has been removed
+        assertEquals(credentialRepo.getCredentialIdsForUsername(USERNAME).size(),2);
+        
+    }
+    
+    @SuppressWarnings("null")
+    private void doAuthenticationDance(final Pair<FlowExecutionResult, FlowExecutionImpl> result,
+            final CredentialRegistration registration) throws Exception {
+        
+        // Do authentication part
+        assertFlowExecutionActive(result.getSecond());
+        assertCurrentStateEquals("CollectUsernameView", result.getSecond());
+        // Do next part of flow, populate the username
+        ExternalContextHolder.setExternalContext(externalContext);
+        setHttpFormRequest("POST", Map.of("j_username", USERNAME));
+        externalContext.setEventId("proceed");        
+        result.getSecond().setCurrentState("CollectUsernameView");
+        result.getSecond().resume(externalContext);        
+        assertFlowExecutionActive(result.getSecond());
+        assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+        
+        final ProfileRequestContext prc = getProfileRequestContextFromConversation(result.getSecond());
+        
+        // Get the challenge that was set into the PublicKeyCredentialRequestOptions. 
+        // otherwise we will end up signing a different one, which will provide its own test.
+        final WebAuthnAuthenticationContext authnContext = getWebAuthnAuthenticationContext(prc);
+        final ByteArray challenge = authnContext.getPublicKeyCredentialRequestOptions().getChallenge();
+        
+        final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
+        assertionResponse = createAssertionReponseFrom(
+                registration.getCredential().getCredentialId().getBytes(), challenge.getBytes());
+
+        final String assertionResponseJson = jsonMapper.writeValueAsString(assertionResponse);
+        
+        // Re-set external context to holder
+        ExternalContextHolder.setExternalContext(externalContext);
+        setHttpFormRequest("POST", Map.of(ExtractPublicKeyCredentialAssertionFromFormRequest.DEFAULT_PARAMETER_NAME, 
+                assertionResponseJson));
+        externalContext.setEventId("proceed");
+        result.getSecond().setCurrentState("DisplayWebAuthnView");
+        result.getSecond().resume(externalContext);
+    }
+
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnRegistrationApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnRegistrationApplicationContextInitializer.java
new file mode 100644
index 0000000..30ca111
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnRegistrationApplicationContextInitializer.java
@@ -0,0 +1,56 @@
+/*
+ * 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 net.shibboleth.idp.plugin.authn.webauthn.flow;
+
+import javax.annotation.Nonnull;
+
+import org.slf4j.Logger;
+import org.springframework.context.ApplicationContextInitializer;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.mock.env.MockPropertySource;
+
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * An {@link ApplicationContextInitializer} which prepends properties.
+ *
+ * <p>This needs to include the original IdP-test-layer properties and has to be
+ * set to {@link Ordered#LOWEST_PRECEDENCE} or things blow up.</p>
+ */
+ at Order(Ordered.LOWEST_PRECEDENCE)
+public class TestWebAuthnRegistrationApplicationContextInitializer
+        implements ApplicationContextInitializer<ConfigurableApplicationContext> {
+
+    /** Class logger. */
+    @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthnRegistrationApplicationContextInitializer.class);
+
+    /** {@inheritDoc} */
+    @Override public void initialize(@Nonnull final ConfigurableApplicationContext applicationContext) {
+        final MockPropertySource mock = new MockPropertySource("passwordless-mock-properties");
+        mock.setProperty("idp.authn.webauthn.usernameless.enabled", "false");
+        // Turn off sessions, less flow complexity
+        mock.setProperty("idp.session.enabled", "false");        
+        mock.setProperty("idp.storage.htmlLocalStorage", "false");
+        // Enable its own WebAuthn authn
+        mock.setProperty("idp.authn.flows", "WebAuthn");
+        // Remove access checks
+        mock.setProperty("idp.authn.webauthn.admin.registration.accessPolicy", "AccessByIPAddress");
+        applicationContext.getEnvironment().getPropertySources().addFirst(mock);
+        log.info("Prepending usernameless properties '{}'", mock.getSource());
+    }
+    
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/MockAuthenticator.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/MockAuthenticator.java
index 3e29b85..1c40f07 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/MockAuthenticator.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/MockAuthenticator.java
@@ -163,6 +163,7 @@ public class MockAuthenticator {
      * 
      * @throws Exception on error.
      */
+    //TODO do we need challenge here, it goes in the clientData?
     public com.yubico.webauthn.data.PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs>  
         createAuthenticatorAttestationResponse(@Nonnull @NotEmpty final String challenge,
                 final Map<String, String> clientData, final byte[] userHandle) throws Exception {

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.


More information about the commits mailing list