[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