[java-idp-plugin-webauthn] branch main updated: JWEBAUTHN-43 - evaluate credential policy rules for all tokens in store before authentication
Phil Smart
philip.smart at jisc.ac.uk
Fri Feb 7 14:24:19 UTC 2025
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=5b27b99d2d00de5f8fa3ee17c6a6dc6029b91dfa
The following commit(s) were added to refs/heads/main by this push:
new 5b27b99 JWEBAUTHN-43 - evaluate credential policy rules for all tokens in store before authentication
5b27b99 is described below
commit 5b27b99d2d00de5f8fa3ee17c6a6dc6029b91dfa
Author: Phil Smart <philip.smart at jisc.ac.uk>
AuthorDate: Fri Feb 7 14:24:16 2025 +0000
JWEBAUTHN-43 - evaluate credential policy rules for all tokens in store
before authentication
- Add a filter policy to both passwordless and 2fa flows. That is, were
the user and their credentials are identified in advance of calling the
WebAuthn API.
- Signal a customisable non-proceed event if the user's credentials are
all filtered by the policy. Otherwise the WebAuthn would not see a list
of allowedCredentials and present all of them for selection (assumed
usernameless authentication).
- Add lots more tests to make sure there is not a bypass from doing
this.
- Some minor nits and cleanups
https://shibboleth.atlassian.net/browse/JWEBAUTHN-43
---
.../client/WebAuthnAuthenticationClient.java | 6 +-
.../impl/YubicoWebAuthnAuthenticationClient.java | 3 +-
.../authn/webauthn/impl/CheckCredentialPolicy.java | 93 ++------
.../impl/CheckUserHandleAgainstUsername.java | 118 ++++++++++
.../CheckUserHandleExistsIfNoAllowCredentials.java | 118 ++++++++++
.../webauthn/impl/FilterCredentialsOnPolicy.java | 148 ++++++++++++
.../LookupRegisteredCredentialsFromUserHandle.java | 6 +-
.../webauthn/impl/ValidateWebAuthnAssertion.java | 4 +-
.../idp/flows/authn/WebAuthn/webauthn-beans.xml | 15 +-
.../idp/flows/authn/WebAuthn/webauthn-flow.xml | 24 +-
.../authn/webauthn/conf/authn/webauthn.properties | 9 +-
.../YubicoWebauthnAuthenticationClientTest.java | 14 +-
.../webauthn/flow/AbstractWebAuthnFlowTest.java | 30 ++-
.../webauthn/flow/TestAdminManagementFlow.java | 2 +-
.../authn/webauthn/flow/TestPasswordlessFlow.java | 253 ++++++++++++++++++++-
...a => TestPasswordlessFlowWithFilterPolicy.java} | 106 +++++----
.../flow/TestPasswordlessFlowWithPolicy.java | 179 ++++++++++++++-
.../authn/webauthn/flow/TestRegistrationFlow.java | 16 +-
.../authn/webauthn/flow/TestSecondFactorFlow.java | 54 ++++-
...a => TestSecondFactorFlowWithFilterPolicy.java} | 122 ++++++----
.../authn/webauthn/flow/TestUsernameslessFlow.java | 176 ++++++++++----
.../flow/TestUsernameslessFlowWithPolicy.java | 77 ++++++-
...hFilterPolicyApplicationContextInitializer.java | 51 +++++
...hFilterPolicyApplicationContextInitializer.java | 50 ++++
.../authn/webauthn/impl/AbstractWebAuthnTest.java | 4 +-
.../webauthn/impl/CheckCredentialPolicyTest.java | 55 ++---
.../impl/FilterCredentialsOnPolicyTest.java | 210 +++++++++++++++++
...kupRegisteredCredentialsFromUserHandleTest.java | 10 +-
.../impl/LookupRegisteredCredentialsTest.java | 2 +-
.../impl/ValidateWebAuthnAssertionTest.java | 2 +-
.../policy/impl/RejectOneCredentialPolicyRule.java | 57 +++++
.../impl/CredentialRegistrationSerializerTest.java | 2 +-
...IdPStorageServiceCredentialRespositoryTest.java | 3 +-
.../webauthn/storage/impl/MockAuthenticator.java | 7 +-
.../test-beans-authentication-reject-policy.xml | 7 +-
35 files changed, 1717 insertions(+), 316 deletions(-)
diff --git a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/WebAuthnAuthenticationClient.java b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/WebAuthnAuthenticationClient.java
index 740c694..fb6dfa5 100644
--- a/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/WebAuthnAuthenticationClient.java
+++ b/webauthn-api/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/WebAuthnAuthenticationClient.java
@@ -77,8 +77,10 @@ public interface WebAuthnAuthenticationClient {
/**
* Validate the PublicKeyCredential authenticator assertion response from an authentication request.
*
- * @param username the username of the users account on the IdP. Should map one-to-one with the user.id.
- * @param userId the user.id of the users account on the IdP. Should match the userHandle in the assertion response
+ * @param username the username of the users account on the IdP as set in the assertion request.
+ * Should map one-to-one with the user.id.
+ * @param userId the user.id of the users account on the IdP as set in the assertion request.
+ * Should match the userHandle in the assertion response
* @param publicKeyCredentialRequestOptions the options used when generating an assertion for authentication.
* @param publicKeyCredentialAssertionResponse the assertion response.
*
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebAuthnAuthenticationClient.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebAuthnAuthenticationClient.java
index 3fcfcbf..e64b741 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebAuthnAuthenticationClient.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebAuthnAuthenticationClient.java
@@ -175,7 +175,8 @@ public class YubicoWebAuthnAuthenticationClient implements WebAuthnAuthenticatio
log.debug("Attempting validation of assumed discoverable credential with userHandle from response "
+ "'{}'", userHandleBase64);
} else {
- log.debug("Attempting validation of credential with username '{}' and userHandle '{}'",
+ log.debug("Attempting validation of credential '{}' with request username '{}' and request "
+ + "userHandle '{}'",authenticatorAssertionResponse.getId().getBase64(),
username, WebAuthnSupport.toBase64OrUnknown(userId));
}
}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
index c8a3f86..3f0b10b 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicy.java
@@ -17,7 +17,6 @@ package net.shibboleth.idp.plugin.authn.webauthn.impl;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;
-import java.util.function.Predicate;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -46,17 +45,15 @@ import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
-import net.shibboleth.shared.logic.PredicateSupport;
import net.shibboleth.shared.primitive.LoggerFactory;
/**
- * A policy engine action that checks with the configured policy if the credential, used to sign the assertion, can
+ * A policy engine action that checks with the configured policy if the credential, used to sign the assertion can
* be used to authenticate. If it is rejected by the policy, a
- * {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION} is returned. If #rejectIfNoCredentialsFound is
- * true, and the credential used to sign the assertion can not be found from the credential repository, a
- * {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION} will be returned, else the policy is ignored.
+ * {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION} is returned. Similarly, if a credential can
+ * not be found to evaluate the policy returns an {@link WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION}.
*
- * @event {EventIds#INVALID_PROFILE_CTX}\
+ * @event {EventIds#INVALID_PROFILE_CTX}
* @event {WebAuthnAuthenticationEventIds#CREDENTIAL_POLICY_REJECTION}
* @pre <pre>ProfileRequestContext.getSubcontext(WebAuthnRegistrationContext.class) != null</pre>
* @post the credential is allowed to be used for authentication, or an error event is triggered
@@ -73,12 +70,6 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
/** Get the userHandle to help find the credential used to sign the assertion.*/
@Nonnull private Function<ProfileRequestContext, byte[]> userHandleLookupStrategy;
- /**
- * Reject the assertion if no credential can be found in the repository, otherwise the policy is ignored.
- * Defaults to false.
- */
- @Nonnull private Predicate<ProfileRequestContext> rejectIfNoCredentialFound;
-
/** The credential policy to check.*/
@Nullable private CredentialPolicy credentialPolicy;
@@ -95,7 +86,6 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
super(new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
compose(new ChildContextLookup<>(AuthenticationContext.class)));
userHandleLookupStrategy = new DefaultUserHandleLookupStrategy();
- rejectIfNoCredentialFound = PredicateSupport.alwaysFalse();
}
/**
@@ -122,30 +112,6 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
"UserHandle lookup strategy can not be null");
}
- /**
- * Set the predicate to determine if finding no credential to test should result in a policy rejection.
- *
- * @param predicate The predicate to set.
- *
- * @since 1.1.0
- */
- public void setRejectIfNoCredentialFoundPredicate(final Predicate<ProfileRequestContext> predicate) {
- checkSetterPreconditions();
- rejectIfNoCredentialFound = Constraint.isNotNull(predicate,
- "rejectIfNoCredentialsFound can not be null");
- }
-
- /**
- * Set the flag to determine if finding no credential to test should result in a policy rejection.
- *
- * @param flag The flag to set.
- *
- * @since 1.1.0
- */
- public void setRejectIfNoCredentialFound(final boolean flag) {
- checkSetterPreconditions();
- rejectIfNoCredentialFound = flag ? PredicateSupport.alwaysTrue() : PredicateSupport.alwaysFalse();
- }
/** {@inheritDoc} */
@Override
@@ -195,16 +161,10 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
final byte[] userHandleBytes = userHandleLookupStrategy.apply(profileRequestContext);
if (userHandleBytes == null) {
- log.debug("{} UserHandle could not be found, policy can not be applied",getLogPrefix());
- if (!rejectIfNoCredentialFound.test(profileRequestContext)) {
- return;
- } else {
- authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
- WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
- ActionSupport.buildEvent(profileRequestContext,
- WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
- return;
- }
+ log.trace("{} UserHandle could not be found, policy rejected credential",getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext,
+ WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ return;
}
final Collection<EnhancedCredentialRecord> registeredCredentials = context.getExistingCredentials();
@@ -217,34 +177,25 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
}).toList();
if (credentials.isEmpty()) {
- log.trace("{} UserHandle '{}' does not have a registered credential, policy can not be applied",getLogPrefix(),
- userHandle.getBase64());
- if (!rejectIfNoCredentialFound.test(profileRequestContext)) {
- return;
- } else {
- authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
- WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
- ActionSupport.buildEvent(profileRequestContext,
- WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
- return;
- }
+ log.trace("{} UserHandle '{}' does not have a registered credential, policy rejected credential",
+ getLogPrefix(), userHandle.getBase64());
+ authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
+ WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ ActionSupport.buildEvent(profileRequestContext,
+ WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ return;
}
// Reject if more than one credential that matches the credentialID and userHandle in the assertion. Should
// never happen.
if (credentials.size() != 1) {
- log.debug("{} Credential ID '{}' for userHandle '{}' has more than one registered credential, "
+ log.trace("{} Credential ID '{}' for userHandle '{}' has more than one registered credential, "
+ "policy can not be applied, rejecting",getLogPrefix(), assertion.getId(),
- userHandle.getHex());
- if (!rejectIfNoCredentialFound.test(profileRequestContext)) {
- return;
- } else {
- authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
- WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
- ActionSupport.buildEvent(profileRequestContext,
- WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
- return;
- }
+ userHandle.getHex());
+ authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
+ WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ ActionSupport.buildEvent(profileRequestContext, WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
+ return;
}
final EnhancedCredentialRecord credentialToEvaluate = credentials.get(0);
@@ -252,7 +203,7 @@ public class CheckCredentialPolicy extends AbstractWebAuthnAction<WebAuthnAuthen
final CredentialPolicyOutcome outcome = localPolicy.evaluate(credentialToEvaluate, profileRequestContext);
if (outcome == CredentialPolicyOutcome.REJECT) {
- log.warn("{} CredentialPolicy '{}' has rejected credential '{}'", getLogPrefix(), localPolicy.getId(),
+ log.trace("{} CredentialPolicy '{}' has rejected credential '{}'", getLogPrefix(), localPolicy.getId(),
credentialToEvaluate.getCredentialRecord().getCredentialIdBase64());
authnContext.ensureSubcontext(AuthenticationErrorContext.class).getClassifiedErrors().add(
WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckUserHandleAgainstUsername.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckUserHandleAgainstUsername.java
new file mode 100644
index 0000000..e05e00e
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckUserHandleAgainstUsername.java
@@ -0,0 +1,118 @@
+/*
+ * 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.impl;
+
+import java.util.Optional;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+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.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.WebAuthnCredentialRepository;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * An action that checks if the userHandle in the authentication assertion refers to the same user as in the WebAuthn
+ * context. Noting, under some circumstances e.g. a passwordless flow where the user and their credentials have
+ * already been identified, the authenticator does not need to provide a userHandle, in which case this action does
+ * nothing.
+ *
+ *
+ * @event {EventIds#INVALID_PROFILE_CTX}
+ * @event {AuthnEventIds#INVALID_CREDENTIALS}
+ * @pre <pre>ProfileRequestContext.getSubcontext(WebAuthnRegistrationContext.class) != null</pre>
+ * @post a non-proceed event is signalled if the username found for the userHandle does not match that in the context
+ *
+ * @since 1.1.0
+ */
+public class CheckUserHandleAgainstUsername extends AbstractWebAuthnAction<WebAuthnAuthenticationContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(CheckUserHandleAgainstUsername.class);
+
+ /** The credential repository to use.*/
+ @NonnullAfterInit private WebAuthnCredentialRepository repository;
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ repository = getCredentialRepository();
+ if (repository == null) {
+ throw new ComponentInitializationException("Credential repository can not be null");
+ }
+ }
+
+ /**
+ * Constructor.
+ */
+ protected CheckUserHandleAgainstUsername() {
+ super(new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+ compose(new ChildContextLookup<>(AuthenticationContext.class)));
+ }
+
+ @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final WebAuthnAuthenticationContext context) {
+
+
+ final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> assertion =
+ context.getPublicKeyCredentialAssertionResponse();
+ if (assertion == null) {
+ log.error("{} Unable to find Assertion in WebAuthn authentication context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return;
+ }
+ final String username = context.getUsername();
+
+ final Optional<ByteArray> userHandle = assertion.getResponse().getUserHandle();
+ if (userHandle.isEmpty()) {
+ log.trace("{} User could not be found, the authenticator did not supply a userHandle", getLogPrefix());
+ } else {
+ final Optional<String> potentialUsername = repository.getUsernameForUserHandle(userHandle.get());
+
+ if (potentialUsername.isEmpty()) {
+ log.trace("{} User could not be found from the supplied userHandle, no registered credentials",
+ getLogPrefix());
+ } else if (username != null && !potentialUsername.get().equals(username)){
+
+ log.trace("{} Username '{}' found from the userHandle was not the same as in the authentication "
+ + "context '{}'",getLogPrefix(), potentialUsername.get(), username);
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.INVALID_CREDENTIALS);
+ return;
+
+ } else {
+ log.trace("{} Username '{}' found from the userHandle matched that in the authentication context",
+ getLogPrefix(), potentialUsername.get());
+ }
+ }
+
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckUserHandleExistsIfNoAllowCredentials.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckUserHandleExistsIfNoAllowCredentials.java
new file mode 100644
index 0000000..2bb2530
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckUserHandleExistsIfNoAllowCredentials.java
@@ -0,0 +1,118 @@
+/*
+ * 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.impl;
+
+import java.util.List;
+import java.util.Optional;
+
+import javax.annotation.Nonnull;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+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 com.yubico.webauthn.data.PublicKeyCredentialDescriptor;
+import com.yubico.webauthn.data.PublicKeyCredentialRequestOptions;
+
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * An action which checks if the userHandle is returned in the assertion response if the set of allowedCredentials
+ * in the request was empty. If they are empty, the userHandle must be returned, if they are not empty the userHandle
+ * may be returned in the response.
+ *
+ * @event {EventIds#INVALID_PROFILE_CTX}
+ * @event {AuthnEventIds#INVALID_CREDENTIALS}
+ * @pre <pre>ProfileRequestContext.getSubcontext(WebAuthnRegistrationContext.class) != null</pre>
+ * @post a non-proceed event occurs if the userHandle was not present when the set of allowed credentials is empty.
+ *
+ * @since 1.1.0
+ */
+public class CheckUserHandleExistsIfNoAllowCredentials extends AbstractWebAuthnAction<WebAuthnAuthenticationContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(CheckUserHandleExistsIfNoAllowCredentials.class);
+
+ /** The stashed assertion from the context.*/
+ private PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs> assertion;
+
+ /** The stashed request options from the context.*/
+ private PublicKeyCredentialRequestOptions requestOptions;
+
+ /**
+ * Constructor.
+ */
+ protected CheckUserHandleExistsIfNoAllowCredentials() {
+ super(new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+ compose(new ChildContextLookup<>(AuthenticationContext.class)));
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(final ProfileRequestContext profileRequestContext,
+ final WebAuthnAuthenticationContext context) {
+
+ assertion = context.getPublicKeyCredentialAssertionResponse();
+ if (assertion == null) {
+ log.error("{} Unable to find Assertion in WebAuthn authentication context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ requestOptions = context.getPublicKeyCredentialRequestOptions();
+ if (requestOptions == null) {
+ log.error("{} Unable to find credential request options in WebAuthn authentication context",
+ getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+
+
+ return true;
+ }
+
+ @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final WebAuthnAuthenticationContext context) {
+
+ final Optional<List<PublicKeyCredentialDescriptor>> allowCredentials = requestOptions.getAllowCredentials();
+ final Optional<ByteArray> userHandle = assertion.getResponse().getUserHandle();
+
+ if (allowCredentials.isEmpty() || allowCredentials.get() == null || allowCredentials.get().isEmpty()) {
+ if (userHandle.isEmpty()) {
+ log.debug("{} Allow credentials are empty and the userHandle was not returned in the response, the "
+ + "userHandle is required in this case", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.INVALID_CREDENTIALS);
+ return;
+ } else {
+ log.trace("{} Allow credentials are empty and the userHandle was returned in the response",
+ getLogPrefix());
+ }
+ } else {
+ log.trace("{} Allow credentials are not empty and the optional userHandle was {}",
+ getLogPrefix(), userHandle.isPresent() ? "returned in the response" :
+ "not returned in the response");
+ }
+ }
+
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/FilterCredentialsOnPolicy.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/FilterCredentialsOnPolicy.java
new file mode 100644
index 0000000..3abd6a3
--- /dev/null
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/FilterCredentialsOnPolicy.java
@@ -0,0 +1,148 @@
+/*
+ * 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.impl;
+
+import java.util.Collection;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy.CredentialPolicyOutcome;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.EnhancedCredentialRecord;
+import net.shibboleth.shared.annotation.constraint.NotEmpty;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * A policy engine that filters the set of existing credentials held in the context based on the policy.
+ *
+ * @event {AuthnEventIds#INVALID_CREDENTIALS}
+ * @pre <pre>ProfileRequestContext.getSubcontext(WebAuthnRegistrationContext.class) != null</pre>
+ * @post the set of credentials known for this user are filtered based on the policy configured
+ *
+ * @since 1.1.0
+ */
+public class FilterCredentialsOnPolicy extends AbstractWebAuthnAction<WebAuthnAuthenticationContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(FilterCredentialsOnPolicy.class);
+
+ /** The credential policy to check.*/
+ @Nullable private CredentialPolicy credentialPolicy;
+
+ /**
+ * The EventID of the event to build if no credentials remain after filtering.
+ */
+ @Nonnull @NotEmpty private String noCredentialsEventId;
+
+ /**
+ * Constructor.
+ */
+ protected FilterCredentialsOnPolicy() {
+ super(new ChildContextLookup<>(WebAuthnAuthenticationContext.class).
+ compose(new ChildContextLookup<>(AuthenticationContext.class)));
+ noCredentialsEventId = AuthnEventIds.NO_CREDENTIALS;
+ }
+
+ /**
+ * Set the EventID of the event to build if no credentials are found and {@code triggerEventOnNoCredentials}
+ * is set.
+ *
+ * @param eventId the eventId to build.
+ */
+ public void setNoCredentialsEventId(@Nonnull @NotEmpty final String eventId) {
+ checkSetterPreconditions();
+ noCredentialsEventId = Constraint.isNotEmpty(eventId, "NoCredentialsEventId can not be null or empty");
+ }
+
+ /**
+ * Set the policy to verify that the credential can be used for authentication.
+ *
+ * @param policy The authenticator policy to set.
+ */
+ public void setCredentialPolicy(@Nullable final CredentialPolicy policy) {
+ checkSetterPreconditions();
+ credentialPolicy = policy;
+ }
+
+ @Override protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final WebAuthnAuthenticationContext context) {
+
+ final CredentialPolicy localPolicy = credentialPolicy;
+ if (localPolicy == null) {
+ // If no policy, nothing can be applied
+ log.trace("{} No authenticator policy to apply", getLogPrefix());
+ return;
+ }
+
+ final Collection<EnhancedCredentialRecord> existingCredentials = context.getExistingCredentials();
+
+ if (existingCredentials.isEmpty()) {
+ log.trace("{} User '{}' does not have any registered credentials, policy can not be applied",getLogPrefix(),
+ context.getUsername());
+ return;
+ }
+
+ log.debug("{} User '{}' has '{}' credentials before policy filtering", getLogPrefix(), context.getUsername(),
+ existingCredentials.size());
+
+ final List<EnhancedCredentialRecord> filteredCredentials =
+ existingCredentials.stream().filter(credentialToEvaluate -> {
+ final CredentialPolicyOutcome outcome = localPolicy.evaluate(credentialToEvaluate, profileRequestContext);
+
+ if (outcome == CredentialPolicyOutcome.ACCEPT) {
+ log.trace("{} CredentialPolicy '{}' has accepted credential '{}'", getLogPrefix(),
+ localPolicy.getId(), credentialToEvaluate.getCredentialRecord().getCredentialIdBase64());
+ return true;
+
+ } else if (outcome == CredentialPolicyOutcome.REJECT) {
+ log.trace("{} CredentialPolicy '{}' has rejected credential '{}', removing credential",
+ getLogPrefix(), localPolicy.getId(),
+ credentialToEvaluate.getCredentialRecord().getCredentialIdBase64());
+ return false;
+
+ } else if (outcome == CredentialPolicyOutcome.IGNORE) {
+ log.trace("{} CredentialPolicy '{}' was not active for credential '{}'", getLogPrefix(),
+ localPolicy.getId(), credentialToEvaluate.getCredentialRecord().getCredentialIdBase64());
+ return true;
+ }
+ return true;
+ }).toList();
+
+ context.setExistingCredentials(filteredCredentials);
+
+ log.debug("{} User '{}' has '{}' credentials after policy filtering", getLogPrefix(), context.getUsername(),
+ filteredCredentials.size());
+
+ if (filteredCredentials.isEmpty()) {
+ log.debug("{} All of '{}''s credentials have been filtered, triggering event '{}' ",
+ getLogPrefix(), context.getUsername(), noCredentialsEventId);
+ ActionSupport.buildEvent(profileRequestContext, noCredentialsEventId);
+ return;
+ }
+
+ }
+
+}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandle.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandle.java
index a11a656..e6fde32 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandle.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandle.java
@@ -49,10 +49,10 @@ import net.shibboleth.shared.primitive.LoggerFactory;
* response and sets them back onto the context. If no credentials exist and the trigger condition is set, an error
* event will be produced.
*
- * <p>Also ensures the username found from the userHandle matches that in the authentication context if supplied e.g.
- * for a passwordless authentication.</p>
+ * <p>Also ensures the username found from the userHandle matches that in the authentication context if supplied.</p>
*
* @event {@link AuthnEventIds#INVALID_AUTHN_CTX}
+ * @event {@link AuthnEventIds#INVALID_CREDENTIALS}
* @post BaseWebAuthnContext.setExistingCredentials() is either null if no existing credentials are found, or contains
* the credentials from the credential repository
*/
@@ -151,7 +151,7 @@ public class LookupRegisteredCredentialsFromUserHandle extends AbstractWebAuthnA
if (username != null && !potentialUsername.get().equals(username)) {
log.debug("{} Username '{}' found from the userHandle was not the same as in the authentication "
+ "context '{}'",getLogPrefix(), potentialUsername.get(), username);
- ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.NO_CREDENTIALS);
+ ActionSupport.buildEvent(profileRequestContext, AuthnEventIds.INVALID_CREDENTIALS);
return;
}
diff --git a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertion.java b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertion.java
index a7274e3..a7e80ab 100644
--- a/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertion.java
+++ b/webauthn-impl/src/main/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertion.java
@@ -193,11 +193,11 @@ public class ValidateWebAuthnAssertion extends AbstractAuditingValidationAction
context.getUsername(), context.getUserId(), localPublicKeyCredentialRequestOptions, assertion);
if (!result.isSuccess()) {
- throw new AssertionFailureException("Assestion was not valid");
+ throw new AssertionFailureException("Assertion was not valid");
}
if (!result.isSignatureCounterValid()) {
- throw new AssertionFailureException("Assestion was not valid, signature count is invalid");
+ throw new AssertionFailureException("Assertion was not valid, signature count is invalid");
}
if (updateSignatureCount.test(profileRequestContext)) {
// Update the signature count with that from the assertion. It has already been validated at this point
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
index 7b7f33f..e17202f 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-beans.xml
@@ -125,6 +125,12 @@
p:noCredentialsEventId="%{idp.authn.webauthn.passwordless.noCredentialsEventId:NoRegisteredWebAuthnCredentials}"
p:webAuthnContextLookupStrategy-ref="shibboleth.ChildLookup.WebAuthnAuthenticationContextFromAuthenticationContext" />
+ <bean id="FilterCredentialsOnPolicy" scope="prototype" parent="AbstractWebAuthnAuthenticationAction"
+ class="net.shibboleth.idp.plugin.authn.webauthn.impl.FilterCredentialsOnPolicy"
+ p:activationCondition="%{idp.authn.webauthn.credential.filter.policy.enabled:false}"
+ p:credentialPolicy="#{getObject('%{idp.authn.webauthn.credential.filter.policy:shibboleth.authn.WebAuthn.ChainedCredentialPolicy}')}"
+ p:noCredentialsEventId="%{idp.authn.webauthn.credential.filter.policy.noCredentialsEventId:NoCredentials}"/>
+
<bean id="GenerateServerChallenge" scope="prototype" parent="AbstractWebAuthnBaseAction"
class="net.shibboleth.idp.plugin.authn.webauthn.impl.GenerateServerChallenge"
p:webAuthnContextLookupStrategy-ref="shibboleth.ChildLookup.WebAuthnAuthenticationContextFromAuthenticationContext"
@@ -153,12 +159,19 @@
p:triggerEventOnNoCredentials="%{idp.authn.webauthn.signalEventOnNoCredentialsRegisteredForUserHandle:false}"
p:noCredentialsEventId="%{idp.authn.webauthn.userHandleNoRegisteredCredentialsEventId:NoCredentialsRegisteredForUserHandle}"/>
+ <bean id="CheckUserHandleExistsIfNoAllowCredentials" scope="prototype" parent="AbstractWebAuthnAuthenticationAction"
+ class="net.shibboleth.idp.plugin.authn.webauthn.impl.CheckUserHandleExistsIfNoAllowCredentials"/>
+
+ <bean id="CheckUserHandleAgainstUsername" scope="prototype" parent="AbstractWebAuthnAuthenticationAction"
+ class="net.shibboleth.idp.plugin.authn.webauthn.impl.CheckUserHandleAgainstUsername"
+ p:credentialRepository="#{getObject('shibboleth.authn.WebAuthn.CredentialRepository') ?: getObject('shibboleth.authn.WebAuthn.DefaultCredentialRepository')}"
+ />
+
<bean id="CheckCredentialPolicy" parent="AbstractWebAuthnAuthenticationAction" scope="prototype"
class="net.shibboleth.idp.plugin.authn.webauthn.impl.CheckCredentialPolicy"
p:credentialRepository="#{getObject('shibboleth.authn.WebAuthn.CredentialRepository') ?: getObject('shibboleth.authn.WebAuthn.DefaultCredentialRepository')}"
p:credentialPolicy="#{getObject('%{idp.authn.webauthn.credential.policy:shibboleth.authn.WebAuthn.ChainedCredentialPolicy}')}"
p:activationCondition="%{idp.authn.webauthn.credential.policy.enabled:false}"
- p:rejectIfNoCredentialFound="%{idp.authn.webauthn.credential.policy.rejectIfNoCredentials:false}"
p:userHandleLookupStrategy="#{getObject('shibboleth.authn.WebAuthn.CredentialPolicyUserHandleLookupStrategy') ?: getObject('DefaultCredentialPolicyUserHandleLookupStrategy')}"/>
<bean id="DefaultCredentialPolicyUserHandleLookupStrategy" scope="prototype"
diff --git a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
index 6468cfb..e316724 100644
--- a/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
+++ b/webauthn-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/authn/WebAuthn/webauthn-flow.xml
@@ -78,8 +78,10 @@
<action-state id="PasswordlessLoginProceed">
<evaluate expression="LookupRegisteredCredentials"/>
+ <evaluate expression="FilterCredentialsOnPolicy"/>
<evaluate expression="AddUserVerificationRequired"/>
<evaluate expression="'proceed'" />
+
<transition on="proceed" to="GenerateAuthenticationCeremonyOptions" />
</action-state>
@@ -100,8 +102,10 @@
<action-state id="SecondFactorLogin">
<evaluate expression="PopulateWebAuthnAuthenticationContextFor2FA"/>
<evaluate expression="LookupRegisteredCredentials"/>
+ <evaluate expression="FilterCredentialsOnPolicy"/>
<evaluate expression="AddUserVerificationNotRequired"/>
<evaluate expression="'proceed'" />
+
<transition on="proceed" to="GenerateAuthenticationCeremonyOptions" />
</action-state>
@@ -163,23 +167,25 @@
</decision-state>
<action-state id="UsernamelessAssertion">
- <!--
- lookup credentials here so we can exit the process before validation if no registered credentials exist and
- the plugin has been configured to trigger a custom event. Also set the known credentials onto the context
- for the credential policies to run.
- -->
+ <evaluate expression="CheckUserHandleExistsIfNoAllowCredentials"/>
<evaluate expression="LookupRegisteredCredentialsFromUserHandle"/>
<evaluate expression="'proceed'" />
<transition on="proceed" to="ValidatePublicKeyCredential" />
</action-state>
- <action-state id="SecondFactorAssertion">
+ <action-state id="SecondFactorAssertion">
+ <evaluate expression="CheckUserHandleExistsIfNoAllowCredentials"/>
+ <!-- TODO The WebAuthn client should also check the username it is given matches to the userHandle in the assertion response. So this is pre-emptive.-->
+ <evaluate expression="CheckUserHandleAgainstUsername"/>
<evaluate expression="'proceed'" />
<transition on="proceed" to="ValidatePublicKeyCredential" />
</action-state>
<action-state id="PasswordlessAssertion">
+ <evaluate expression="CheckUserHandleExistsIfNoAllowCredentials"/>
+ <!-- TODO The WebAuthn client should also check the username it is given matches to the userHandle in the assertion response. So this is pre-emptive.-->
+ <evaluate expression="CheckUserHandleAgainstUsername"/>
<evaluate expression="'proceed'" />
<transition on="proceed" to="ValidatePublicKeyCredential" />
</action-state>
@@ -190,8 +196,10 @@
<evaluate expression="'proceed'" />
<transition on="proceed" to="proceed" />
- <!-- Import here we backtrack to a suitable action. For example, regenerate the challenge and options but
- we do not need a new username input etc. -->
+ <!--
+ Important here we backtrack to a suitable action. For example, regenerate the challenge and options but
+ we do not need a new username.
+ -->
<transition on="CredentialPolicyRejection" to="GenerateAuthenticationCeremonyOptions"/>
<!-- The default invalid credentials transition would be InvalidCredentials, configuring this event instead allows control to be returned to the view -->
<transition on="InvalidWebAuthnCredential" to="GenerateAuthenticationCeremonyOptions"/>
diff --git a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
index 162bea3..3efde48 100644
--- a/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
+++ b/webauthn-impl/src/main/resources/net/shibboleth/idp/plugin/authn/webauthn/conf/authn/webauthn.properties
@@ -64,10 +64,15 @@ idp.authn.webauthn.supportedPrincipals = \
# The ID of the bean that supplies the c14n flows that are applied to the username entered during the passwordless flow
#idp.authn.webauthn.passwordless.c14n.postUsernameFlows = shibboleth.PostLoginSubjectCanonicalizationFlows
+# Enable the credential filter policy engine for passwordless and second factor flows
+#idp.authn.webauthn.credential.filter.policy.enabled = false
+# Set the credential policies to use, defaults to a chained set of policies
+#idp.authn.webauthn.credential.filter.policy = shibboleth.authn.WebAuthn.ChainedCredentialPolicy
+# If there are no credentials left after filtering, the following non-proceed event is signalled
+#idp.authn.webauthn.credential.filter.policy.noCredentialsEventId = NoCredentials
+
# Enable the credential policy engine
#idp.authn.webauthn.credential.policy.enabled = false
-# Should the policy reject the credential used to sign the assertion if no credentials can be found in the repository for the user
-#idp.authn.webauthn.credential.policy.rejectIfNoCredentials = false
# Set the credential policies to use, defaults to a chained set of policies
#idp.authn.webauthn.credential.policy = shibboleth.authn.WebAuthn.ChainedCredentialPolicy
# When using the default chained policy, which policy list should we use?
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java
index e2ba9c7..429452e 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/client/impl/YubicoWebauthnAuthenticationClientTest.java
@@ -120,7 +120,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegistrationResult registration =
client.validateAuthenticatorAttestationResponse(credentialCreationOptions, attestation);
@@ -138,7 +138,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegistrationResult registration =
client.validateAuthenticatorAttestationResponse(credentialCreationOptions, attestation);
@@ -155,7 +155,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegistrationResult registration =
client.validateAuthenticatorAttestationResponse(credentialCreationOptions, attestation);
@@ -172,7 +172,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
@@ -246,7 +246,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
@@ -291,7 +291,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
@@ -337,7 +337,7 @@ public class YubicoWebauthnAuthenticationClientTest extends AbstractWebAuthnTest
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
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 a5c4dde..f36c055 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
@@ -111,6 +111,9 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
protected final static String USERNAME = "test-user";
protected final static String DISPLAY_NAME = "test user";
+
+ /** Base64 to represent the ID of a credential that will be filtered by the rejection policy".*/
+ protected final static String FILTERED_CREDENTIAL_ID = "ZmlsdGVyZWQtY3JlZGVudGlhbA==";
protected String flowId;
@@ -210,12 +213,13 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
* @param username the username
* @param display name the display name
* @param userHandleB64 the user handle base64 encoded
+ * @param credentialIdOverride the credential ID. Auto-generated if {@code null}.
* @return the credential registration
*
* @throws Exception on error
*/
protected CredentialRecord createCredentialRegistration(final String username, final String displayName,
- final String userHandleB64) throws Exception {
+ final String userHandleB64, final byte[] credentialIdOverride) throws Exception {
final var user = UserIdentity.builder()
.name(username)
@@ -225,7 +229,7 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
- createAttestationReponse();
+ createAttestationReponse(userHandleB64, credentialIdOverride);
final var barray = ByteArray.fromBase64(userHandleB64);
assert barray.getBase64().equals(userHandleB64);
@@ -418,14 +422,18 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
/**
* Create a credential registration attestation response from the mock authenticator.
*
+ * @param userHandleB64 the userHandle base64 encoded
+ * @param credentialIdOverride the credential ID. Auto-generated if {@code null}.
+ *
* @return the credential registration
*
* @throws Exception on error
*/
protected PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs>
- createAttestationReponse() throws Exception {
+ createAttestationReponse(final String userHandleB64, final byte[] credentialIdOverride)
+ throws Exception {
- return createAttestationReponse(Base64Support.decode(CHALLENGE_B64), USER_HANDLE_B64);
+ return createAttestationReponse(Base64Support.decode(CHALLENGE_B64), userHandleB64, credentialIdOverride);
}
@@ -433,15 +441,16 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
* Create a credential registration attestation response from the mock authenticator.
*
* @param challenge the challenge
+ * @param credentialIdOverride the credential ID. Auto-generated if {@code null}.
*
* @return the credential registration
*
* @throws Exception on error
*/
protected PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs>
- createAttestationReponse(final byte[] challenge) throws Exception {
+ createAttestationReponse(final byte[] challenge, final byte[] credentialIdOverride) throws Exception {
- return createAttestationReponse(challenge, USER_HANDLE_B64);
+ return createAttestationReponse(challenge, USER_HANDLE_B64, credentialIdOverride);
}
/**
@@ -450,19 +459,22 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
* @param challenge the challenge
* @param userHandleB64 the userHandle to use. Note this is just stashed in the authenticator, the attestation
* response does not contain a userhandle.
+ * @param credentialIdOverride the credential ID. Auto-generated if {@code null}.
+ *
* @return the credential registration
*
* @throws Exception on error
*/
protected PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs>
- createAttestationReponse(final byte[] challenge, final String userHandleB64) throws Exception {
+ createAttestationReponse(final byte[] challenge, final String userHandleB64,
+ final byte[] credentialIdOverride) 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(userHandleB64));
+ Base64Support.decode(userHandleB64), credentialIdOverride);
return attestation;
}
@@ -479,7 +491,7 @@ public abstract class AbstractWebAuthnFlowTest extends AbstractFlowTest {
//need to create on first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
- createAttestationReponse();
+ createAttestationReponse(USER_HANDLE_B64, null);
final Map<String, String> clientDataGet = createClientData("webauthn.get", ORIGIN, CHALLENGE_B64);
// Now generate an assertion (authentication) and check it is valid
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestAdminManagementFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestAdminManagementFlow.java
index 603a408..4bc2693 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestAdminManagementFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestAdminManagementFlow.java
@@ -76,7 +76,7 @@ public class TestAdminManagementFlow extends AbstractWebAuthnFlowTest{
public void testManagementFlow_RemoveCredential() throws Exception {
//Register a credential for use and to delete.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java
index 57e6bea..7844132 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java
@@ -31,6 +31,7 @@ import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
import com.yubico.webauthn.data.PublicKeyCredential;
+import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
@@ -62,13 +63,27 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
super(FLOW_ID);
}
- /* Should end on username collection when starting the flow.*/
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credential</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>User selects the credential from the provider, authenticator signs challenge</li>
+ * <li>UserHandle is returned, and the credential for that userHandle matches the username entered</li>
+ * <li>(Success) The WebAuthn client validates the credential</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testPasswordlessFlow() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
@@ -118,16 +133,168 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
}
- /*
- * Tests the passwordless flow when there is not userHandle in the assertion response. Which is allowable if
- * the 'allowedCredentials' are supplied to the WebAuthn get request.
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 0 credentials</li>
+ * <li>Credential request options contains an empty allowCredentials (like a usernameless flow)</li>
+ * <li>User selects a credential from the provider, authenticator signs challenge</li>
+ * <li>UserHandle is returned, but no credentials can be found to check against the username entered</li>
+ * <li>fail, on WebAuthn validation step 6, no credential</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
+ @Test
+ public void testPasswordlessFlow_UsernameHasNoCredentials() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("CollectUsernameView", result.getSecond());
+
+ // Do next part of flow, populate the username
+ ExternalContextHolder.setExternalContext(externalContext);
+ // Enter a user who has no credentials.
+ 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());
+ assertPublicKeyCredentialRequestOptions(prc, false, true);
+
+ // Do assertion validation half of flow
+
+ // 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(), false);
+
+ 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);
+
+ //Credential in assertion does not belong to the username entered. The user entered had no credentials.
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.INVALID_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
+
+ }
+
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 0 credentials</li>
+ * <li>Credential request options contains an empty allowCredentials (like a usernameless flow)</li>
+ * <li>User selects a different credential from the provider, authenticator signs challenge</li>
+ * <li>UserHandle is returned, and the credential for that userHandle does not match the username entered</li>
+ * <li>(Fail) the user.id for the username entered is not the same as the userHandle in the assertion.</li>
+ * </ol>
+ *
+ * Note, In this scenario, it maybe reasonable to allow the user to select a credential they poses for a
+ * different username than the one entered. But for now, be safe and block it.
+ *
+ *
+ * @throws Exception on error
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testPasswordlessFlow_UsernameFromUserHandleDoesNotMatchUsernameInContext() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("CollectUsernameView", result.getSecond());
+
+ // Do next part of flow, populate the username
+ ExternalContextHolder.setExternalContext(externalContext);
+ // Add a username input here, which is different than the user that registered the credential
+ setHttpFormRequest("POST", Map.of("j_username", "different-username"));
+ externalContext.setEventId("proceed");
+
+ result.getSecond().setCurrentState("CollectUsernameView");
+ result.getSecond().resume(externalContext);
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ assertPublicKeyCredentialRequestOptions(prc, false, true);
+
+ // Do assertion validation half of flow
+
+ // 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(), false);
+
+ 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);
+
+ // assert end conditions, no existing result or principal. So this should produce a Username principal
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.INVALID_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
+
+ }
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credentials</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>User selects the credential from the provider, authenticator signs challenge</li>
+ * <li>UserHandle is NOT returned, and the userHandle can not be checked against the username input</li>
+ * <li>(Success) the public key registration belongs to the user.id and username in the context</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
*/
@SuppressWarnings("null")
@Test
public void testPasswordlessFlow_NoUserHandleInAssertion() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
@@ -176,5 +343,79 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
assertAuthenticationSuccessConditions(prc, true);
}
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credentials</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>User selects a different credential from the provider (maybe the user blanked out the allowCredentials in the
+ * WebAuthn request), authenticator signs challenge</li>
+ * <li>UserHandle is NOT returned, and the userHandle can not be checked against the username input</li>
+ * <li>fail, on WebAuthn validation step 5, unrequested credential</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testPasswordlessFlow_NoUserHandleInAssertion_UserChooseADifferentCredential() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final CredentialRecord differentRegistration =
+ createCredentialRegistration("different-user", DISPLAY_NAME, "Cuxm3QTpyOdDlzTLDY9gFQ==", null);
+ credentialRepo.addRegistrationByUsername("different-user", differentRegistration);
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ 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());
+ assertPublicKeyCredentialRequestOptions(prc, true, true);
+
+ // Do assertion validation half of flow
+
+ // 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();
+
+ // The user managed to select a credential for another user. No UserHandle is returned.
+ final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
+ assertionResponse = createAssertionReponseFrom(
+ differentRegistration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
+
+ 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);
+
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.INVALID_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
+
+ }
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithFilterPolicy.java
similarity index 65%
copy from webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java
copy to webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithFilterPolicy.java
index f93b363..8850b48 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithFilterPolicy.java
@@ -14,6 +14,8 @@
package net.shibboleth.idp.plugin.authn.webauthn.flow;
+import static org.testng.Assert.assertEquals;
+
import java.util.Map;
import javax.annotation.Nonnull;
@@ -29,14 +31,16 @@ import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
import com.yubico.webauthn.data.PublicKeyCredential;
+import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.codec.Base64Support;
import net.shibboleth.shared.collection.Pair;
/**
- * Flow tests for the passwordless flow.
+ * Flow tests for the passwordless flow where the 'filter credentials after lookup' policy engine is active.
*/
@ContextConfiguration(
locations = {
@@ -45,11 +49,11 @@ import net.shibboleth.shared.collection.Pair;
"classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans.xml"},
initializers = {
TestWebAuthnEnvironmentApplicationContextInitializer.class,
- TestWebAuthnPasswordlessWithPolicyApplicationContextInitializer.class
+ TestWebAuthnPasswordlessWithFilterPolicyApplicationContextInitializer.class
}
)
/* The policy is set to REJECT.*/
-public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
+public class TestPasswordlessFlowWithFilterPolicy extends AbstractWebAuthnFlowTest{
/** Flow ID. */
@Nonnull public static final String FLOW_ID = "authn/WebAuthn";
@@ -57,17 +61,33 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
/**
* Constructor.
*/
- protected TestPasswordlessFlowWithPolicy() {
+ protected TestPasswordlessFlowWithFilterPolicy() {
super(FLOW_ID);
}
- /* Should end on username collection when starting the flow.*/
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credential</li>
+ * <li>Credentials are filtered. 0 remain.</li>
+ * <li>Credential request options contains an empty allowCredentials</li>
+ * <li>(Fail) as all credentials have been filtered</li>
+ * </ol>
+ *
+ * <p>If we do not fail early, the user could select a credential that was filtered.</p>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
- public void testPasswordlessFlow_WithPolicy() throws Exception {
+ public void testPasswordlessFlow_WithPolicy_AllFiltered() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Base64Support.decodeURLSafe(FILTERED_CREDENTIAL_ID));
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
@@ -86,49 +106,42 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
result.getSecond().setCurrentState("CollectUsernameView");
result.getSecond().resume(externalContext);
- assertFlowExecutionActive(result.getSecond());
- assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
- assertPublicKeyCredentialRequestOptions(prc, true, true);
-
- // Do assertion validation half of flow
-
- // 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(), false);
-
- 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);
-
- // Policy failure will return the user to the webauthn login page
- assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ //Fail if all credentials have been filtered
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.NO_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
}
- /*
- * Tests the passwordless flow when there is no userHandle in the assertion response. Which is allowable if
- * a set of 'allowedCredentials' are supplied to the WebAuthn get request. Policy is set to reject, so it should
- * be rejected.
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 2 credentialS</li>
+ * <li>Credentials are filtered. 1 remains.</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>The user selects the other credential that was filtered (they modify the WebAuthn request).
+ * <li>No UserHandle in the response.</li>
+ * <li>(Fail) the filtered credential was used, and that had not been requested in the options, so the WebAuthn
+ * client check (step5) fails.</li>
+ * </ol>
+ *
+ *
+ *
+ * @throws Exception on error
*/
@SuppressWarnings("null")
@Test
- public void testPasswordlessFlow_WithPolicy_NoUserHandleInAssertion() throws Exception {
+ public void testPasswordlessFlow_WithPolicy_OneFiltered_SelectFilteredCredential_NoUserHandle() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
+ final CredentialRecord secondRegistration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Base64Support.decodeURLSafe(FILTERED_CREDENTIAL_ID));
+ credentialRepo.addRegistrationByUsername(USERNAME, secondRegistration);
+
final var prc = buildProfileRequestContext(false, false, USERNAME);
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
@@ -147,6 +160,7 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
assertFlowExecutionActive(result.getSecond());
assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ // We expect all the credentials to be filtered by this point
assertPublicKeyCredentialRequestOptions(prc, true, true);
// Do assertion validation half of flow
@@ -156,9 +170,12 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
final WebAuthnAuthenticationContext authnContext = getWebAuthnAuthenticationContext(prc);
final ByteArray challenge = authnContext.getPublicKeyCredentialRequestOptions().getChallenge();
+ // Blank the userHandle when creating the assertion response.
final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
+ // Blank userHandle in response as no allow credentials, should not happen if we have filtered all the
+ // credentials
assertionResponse = createAssertionReponseFrom(
- registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
+ secondRegistration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
final String assertionResponseJson = jsonMapper.writeValueAsString(assertionResponse);
@@ -170,9 +187,12 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
result.getSecond().setCurrentState("DisplayWebAuthnView");
result.getSecond().resume(externalContext);
- // Policy failure will return the user to the webauthn login page
- assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ // Require userHandle if no allowCredentials.
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.INVALID_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
}
+
+
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java
index f93b363..5292bce 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlowWithPolicy.java
@@ -14,6 +14,8 @@
package net.shibboleth.idp.plugin.authn.webauthn.flow;
+import static org.testng.Assert.assertEquals;
+
import java.util.Map;
import javax.annotation.Nonnull;
@@ -32,11 +34,12 @@ import com.yubico.webauthn.data.PublicKeyCredential;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.codec.Base64Support;
import net.shibboleth.shared.collection.Pair;
/**
- * Flow tests for the passwordless flow.
+ * Flow tests for the passwordless flow where the 'filter credential after authentication' policy engine is active
*/
@ContextConfiguration(
locations = {
@@ -48,7 +51,7 @@ import net.shibboleth.shared.collection.Pair;
TestWebAuthnPasswordlessWithPolicyApplicationContextInitializer.class
}
)
-/* The policy is set to REJECT.*/
+
public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
/** Flow ID. */
@@ -61,13 +64,25 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
super(FLOW_ID);
}
- /* Should end on username collection when starting the flow.*/
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credential</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>(Fail) the credential is filtered by the policy. </li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
- public void testPasswordlessFlow_WithPolicy() throws Exception {
+ public void testPasswordlessFlow_WithPolicy_Filtered() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Base64Support.decodeURLSafe(FILTERED_CREDENTIAL_ID));
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
@@ -116,17 +131,94 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
}
- /*
- * Tests the passwordless flow when there is no userHandle in the assertion response. Which is allowable if
- * a set of 'allowedCredentials' are supplied to the WebAuthn get request. Policy is set to reject, so it should
- * be rejected.
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 0 credential</li>
+ * <li>Credential request options contains an empty allowCredentials</li>
+ * <li>(fail) the policy will reject the credential as the userHandle does not match to a known credential</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testPasswordlessFlow_WithPolicy_NoCredentials() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Base64Support.decodeURLSafe(FILTERED_CREDENTIAL_ID));
+
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("CollectUsernameView", result.getSecond());
+
+ // Do next part of flow, populate the username
+ ExternalContextHolder.setExternalContext(externalContext);
+ setHttpFormRequest("POST", Map.of("j_username", "no-credentials-user"));
+ externalContext.setEventId("proceed");
+
+ result.getSecond().setCurrentState("CollectUsernameView");
+ result.getSecond().resume(externalContext);
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ assertPublicKeyCredentialRequestOptions(prc, false, true);
+
+ // Do assertion validation half of flow
+
+ // 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(), false);
+
+ 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);
+
+ // Policy failure will return the user to the webauthn login page
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+
+ }
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credential</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>UserHandle is not returned in the response, which is allowed.</li>
+ * <li>User.id is taken from the context.</li>
+ * <li>(Fail) the credential is filtered by the policy. </li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
*/
@SuppressWarnings("null")
@Test
public void testPasswordlessFlow_WithPolicy_NoUserHandleInAssertion() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Base64Support.decodeURLSafe(FILTERED_CREDENTIAL_ID));
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
@@ -174,5 +266,72 @@ public class TestPasswordlessFlowWithPolicy extends AbstractWebAuthnFlowTest{
assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
}
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>Username entered has 1 credential</li>
+ * <li>Credential request options contains an allowCredentials with 1 credential</li>
+ * <li>UserHandle is not returned in the response, which is allowed.</li>
+ * <li>(Success) the credential is not filtered and is valid</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testPasswordlessFlow_WithPolicy_NotFiltered() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ 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());
+ assertPublicKeyCredentialRequestOptions(prc, true, true);
+
+ // Do assertion validation half of flow
+
+ // 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(), true);
+
+ 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);
+
+ assertEquals(result.getSecond().getOutcome().getId(), "proceed");
+ assertAuthenticationSuccessConditions(prc, true);
+
+ }
}
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
index 916845d..96dd034 100644
--- 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
@@ -81,7 +81,7 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
public void testRegistrationFlow_RemoveCredential() throws Exception {
//Register a credential for use and to delete.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
@@ -121,12 +121,12 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
public void testRegistrationFlow_RemoveCredentialFromDifferentUser() throws Exception {
//Register a credential for use
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
// Register another uses credential and try and delete it
final CredentialRecord registrationAnotherUser =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername("another-user", registrationAnotherUser);
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
@@ -162,7 +162,7 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
public void testRegistrationFlow_AddCredential() throws Exception {
//Register a credential for use for authentication.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
@@ -181,7 +181,7 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
final WebAuthnRegistrationContext authnContext = getWebAuthnRegistrationContext(prc);
final ByteArray challenge = authnContext.getPublicKeyCredentialCreationOptions().getChallenge();
- final var attestationResponse = createAttestationReponse(challenge.getBytes());
+ final var attestationResponse = createAttestationReponse(challenge.getBytes(), null);
final String attestationResponseJson = jsonMapper.writeValueAsString(attestationResponse);
setHttpFormRequest("POST", Map.of(ExtractPublicKeyCredentialAttestationFromFormRequest.DEFAULT_PARAMETER_NAME,
attestationResponseJson,
@@ -213,14 +213,14 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
final var anotherUserDisplayName = "another user";
final var anotherUserUserIdB64 = "2fixsNecxhORZpf4LMU9nA==";
final CredentialRecord registration =
- createCredentialRegistration(anotherUserUsername, anotherUserDisplayName, anotherUserUserIdB64);
+ createCredentialRegistration(anotherUserUsername, anotherUserDisplayName, anotherUserUserIdB64, null);
credentialRepo.addRegistrationByUsername(anotherUserUsername, registration);
assertEquals(credentialRepo.getCredentialIdsForUsername(anotherUserUsername).size(),1);
//Register a credential for the actual user to authenticate with
final CredentialRecord registrationCorrectUser =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registrationCorrectUser);
assertEquals(credentialRepo.getCredentialIdsForUsername(USERNAME).size(),1);
@@ -271,7 +271,7 @@ public class TestRegistrationFlow extends AbstractWebAuthnFlowTest{
// Create a credential for the other users UserId/UserHandle. This should be stored against the actual user
// We should not be able to trick the system into one user registering a credential for another user.
- final var attestationResponse = createAttestationReponse(challenge.getBytes());
+ final var attestationResponse = createAttestationReponse(challenge.getBytes(), null);
final String attestationResponseJson = jsonMapper.writeValueAsString(attestationResponse);
setHttpFormRequest("POST", Map.of(ExtractPublicKeyCredentialAttestationFromFormRequest.DEFAULT_PARAMETER_NAME,
attestationResponseJson,
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java
index be9dfc6..5351b30 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlow.java
@@ -71,7 +71,7 @@ public class TestSecondFactorFlow extends AbstractWebAuthnFlowTest{
public void testSecondFactorFlow() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
@@ -111,6 +111,56 @@ public class TestSecondFactorFlow extends AbstractWebAuthnFlowTest{
}
+ /*
+ * Tests the 2fa flow when there is not userHandle in the assertion response. Which is allowable if
+ * the 'allowedCredentials' are supplied to the WebAuthn get request.
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testSecondFactorFlow_NoUserHandle() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+ buildMfaContext(prc.ensureSubcontext(AuthenticationContext.class), "authn/Password");
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ assertPublicKeyCredentialRequestOptions(prc, true, false);
+
+ // Do assertion validation half of flow
+
+ // 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>
+ // Blank the userHandle
+ assertionResponse = createAssertionReponseFrom(
+ registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
+
+ 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);
+
+ // assert end conditions, assume existing username principal from the faked password flow
+ assertEquals(result.getSecond().getOutcome().getId(), "proceed");
+ assertAuthenticationSuccessConditions(prc, false);
+
+ }
+
/*
* The flow will proceed as if first factor passwordless, as no acceptable previous result. So we will end up on the
* CollectUsernameView instead of DisplayWebAuthnView.
@@ -120,7 +170,7 @@ public class TestSecondFactorFlow extends AbstractWebAuthnFlowTest{
public void testSecondFactorFlow_WrongPreviousResult() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlowWithFilterPolicy.java
similarity index 61%
copy from webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java
copy to webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlowWithFilterPolicy.java
index 57e6bea..f552bc0 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestPasswordlessFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestSecondFactorFlowWithFilterPolicy.java
@@ -19,6 +19,7 @@ import static org.testng.Assert.assertEquals;
import java.util.Map;
import javax.annotation.Nonnull;
+import javax.security.auth.Subject;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.webflow.context.ExternalContextHolder;
@@ -31,26 +32,31 @@ import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
import com.yubico.webauthn.data.PublicKeyCredential;
+import net.shibboleth.idp.authn.AuthenticationResult;
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.authn.context.AuthenticationContext;
+import net.shibboleth.idp.authn.context.MultiFactorAuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
+import net.shibboleth.shared.codec.Base64Support;
import net.shibboleth.shared.collection.Pair;
/**
- * Flow tests for the passwordless flow.
+ * Flow tests for the 2fa flow.
*/
@ContextConfiguration(
locations = {
"classpath*:/META-INF/net.shibboleth.idp/postconfig.xml",
- "classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans-authentication.xml",
+ "classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans-authentication-reject-policy.xml",
"classpath*:/net/shibboleth/idp/plugin/authn/webauthn/test-beans.xml"},
initializers = {
TestWebAuthnEnvironmentApplicationContextInitializer.class,
- TestWebAuthnPasswordlessApplicationContextInitializer.class
+ TestWebAuthn2FaWithFilterPolicyApplicationContextInitializer.class
}
)
-public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
+public class TestSecondFactorFlowWithFilterPolicy extends AbstractWebAuthnFlowTest{
/** Flow ID. */
@Nonnull public static final String FLOW_ID = "authn/WebAuthn";
@@ -58,38 +64,27 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
/**
* Constructor.
*/
- protected TestPasswordlessFlow() {
+ protected TestSecondFactorFlowWithFilterPolicy() {
super(FLOW_ID);
}
- /* Should end on username collection when starting the flow.*/
@SuppressWarnings("null")
@Test
- public void testPasswordlessFlow() throws Exception {
+ public void testSecondFactorFlow_WithPolicy_Accepted() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
+ buildMfaContext(prc.ensureSubcontext(AuthenticationContext.class), "authn/Password");
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
- 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());
- assertPublicKeyCredentialRequestOptions(prc, true, true);
+ assertPublicKeyCredentialRequestOptions(prc, true, false);
// Do assertion validation half of flow
@@ -112,43 +107,54 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
result.getSecond().setCurrentState("DisplayWebAuthnView");
result.getSecond().resume(externalContext);
- // assert end conditions, no existing result or principal. So this should produce a Username principal
+ // assert end conditions, assume existing username principal from the faked password flow
assertEquals(result.getSecond().getOutcome().getId(), "proceed");
- assertAuthenticationSuccessConditions(prc, true);
+ assertAuthenticationSuccessConditions(prc, false);
}
- /*
- * Tests the passwordless flow when there is not userHandle in the assertion response. Which is allowable if
- * the 'allowedCredentials' are supplied to the WebAuthn get request.
- */
@SuppressWarnings("null")
@Test
- public void testPasswordlessFlow_NoUserHandleInAssertion() throws Exception {
+ public void testSecondFactorFlow_WithPolicy_Filtered() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64,
+ Base64Support.decodeURLSafe(FILTERED_CREDENTIAL_ID));
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, USERNAME);
+ buildMfaContext(prc.ensureSubcontext(AuthenticationContext.class), "authn/Password");
final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
- assertFlowExecutionActive(result.getSecond());
- assertCurrentStateEquals("CollectUsernameView", result.getSecond());
+ //Fail if all credentials have been filtered
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.NO_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
- // 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);
+ }
+
+ /*
+ * Tests the 2fa flow when there is not userHandle in the assertion response. Which is allowable if
+ * the 'allowedCredentials' are supplied to the WebAuthn get request.
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testSecondFactorFlow_NoUserHandle_NotFiltered() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+ buildMfaContext(prc.ensureSubcontext(AuthenticationContext.class), "authn/Password");
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
assertFlowExecutionActive(result.getSecond());
assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
- assertPublicKeyCredentialRequestOptions(prc, true, true);
+ assertPublicKeyCredentialRequestOptions(prc, true, false);
// Do assertion validation half of flow
@@ -158,6 +164,7 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
final ByteArray challenge = authnContext.getPublicKeyCredentialRequestOptions().getChallenge();
final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
+ // Blank the userHandle
assertionResponse = createAssertionReponseFrom(
registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
@@ -171,10 +178,45 @@ public class TestPasswordlessFlow extends AbstractWebAuthnFlowTest{
result.getSecond().setCurrentState("DisplayWebAuthnView");
result.getSecond().resume(externalContext);
- // assert end conditions, no existing result or principal. So this should produce a Username principal
+ // assert end conditions, assume existing username principal from the faked password flow
assertEquals(result.getSecond().getOutcome().getId(), "proceed");
- assertAuthenticationSuccessConditions(prc, true);
+ assertAuthenticationSuccessConditions(prc, false);
+
+ }
+
+ /*
+ * The flow will proceed as if first factor passwordless, as no acceptable previous result. So we will end up on the
+ * CollectUsernameView instead of DisplayWebAuthnView.
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testSecondFactorFlow_WrongPreviousResult() throws Exception {
+ //Register a credential for use.
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final var prc = buildProfileRequestContext(false, false, USERNAME);
+ buildMfaContext(prc.ensureSubcontext(AuthenticationContext.class), "authn/Wrong");
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ // Stop here, because we are not testing the passwordless flow that we are now in.
+ assertCurrentStateEquals("CollectUsernameView", result.getSecond());
+
+
+
+ }
+
+ private void buildMfaContext(final AuthenticationContext ac, final String previousFlow) {
+ final MultiFactorAuthenticationContext mfaCtx = new MultiFactorAuthenticationContext();
+ // Create a fake password flow result
+ final AuthenticationResult result = new AuthenticationResult("password", new Subject());
+ mfaCtx.getActiveResults().put(previousFlow, result);
+ ac.addSubcontext(mfaCtx);
}
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlow.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlow.java
index a20c4ad..a26c296 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlow.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlow.java
@@ -63,13 +63,26 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
super(FLOW_ID, "proceed");
}
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>Credential matches to a known registered credential</li>
+ * <li>Success, WebAuthn client validates the credential against the one stored.</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -107,28 +120,29 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
assertAuthenticationSuccessConditions(prc, true);
}
-
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>UserHandle is not included in the response, but it should.'(UserHandle) Discoverable credentials store this
+ * identifier and MUST return it as response.userHandle in authentication ceremonies started with an empty
+ * allowCredentials argument.'</li>
+ * <li>Fail, a userHandle is required in the response if we have not already identified the user.</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
- public void testUsernamelessFlow_WebAuthnUsernameDifferentThanIdPUsername() throws Exception {
+ public void testUsernamelessFlow_NoUserHandleInResponse() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
-
- // Now change the username to be different than the user in the WebAuthn identity
- final var regWithDifferentUsername = CredentialRecord.builder()
- .withUserIdentity(registration.getUserIdentity())
- .withUsername("different-idp-username")
- .withTransports(registration.getTransports())
- .withRegistrationTime(registration.getRegistrationTime())
- .withCredential(registration.getCredential())
- .withCredentialNickname(registration.getNickname())
- .withDiscoverable(registration.isDiscoverable())
- .withUserVerified(registration.isUserVerified())
- .build();
-
- credentialRepo.addRegistrationByUsername("different-idp-username", regWithDifferentUsername);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -147,8 +161,9 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
final ByteArray challenge = authnContext.getPublicKeyCredentialRequestOptions().getChallenge();
final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
+ // Blank userHandle in assertion
assertionResponse = createAssertionReponseFrom(
- registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), false);
+ registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
final String assertionResponseJson = jsonMapper.writeValueAsString(assertionResponse);
@@ -160,19 +175,32 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
result.getSecond().setCurrentState("DisplayWebAuthnView");
result.getSecond().resume(externalContext);
- // assert end conditions, no existing result or principal. So this should produce a Username principal
- assertEquals(result.getSecond().getOutcome().getId(), "proceed");
- assertAuthenticationSuccessConditionsWithUsername(prc, true, "different-idp-username");
+ // This should fail, as userHandle is required in a usernameless flow (where no allowCredentials are set)
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.INVALID_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
}
-
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>User Verification did not occur</li>
+ * <li>Credential matches to a known registered credential</li>
+ * <li>fail, on WebAuthn validation step 17, UV required</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_UserNotVerified() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -213,13 +241,26 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
}
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>bad signature added</li>
+ * <li>Credential matches to a known registered credential</li>
+ * <li>fail, on WebAuthn validation step 20, bad signature</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_BadSignature() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -259,13 +300,26 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
}
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>wrong origin added</li>
+ * <li>Credential matches to a known registered credential</li>
+ * <li>fail, on WebAuthn validation step 13, incorrect origin</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_WrongOrigin() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -305,12 +359,20 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
}
- /**
- * Test a validation error that the user identified by the overriden assertion userHandle is not in possession of
- * the credential used to sign the assertion response (as identified by the credentialID in the authenticator data
- * in the response).
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>UserHandle in response does not match to the credential used to sign the request</li>
+ * <li>Credential used to sign the request does not match to userHandle</li>
+ * <li>Fail, WebAuthn client fails to verify the credential because the credential (by its ID) is not registered
+ * for the userHandle in the authentication assertion. mWebAuthn validation step 6, no credential</li></li>
+ * </ol>
+ *
*
- * @throws Exception
+ * @throws Exception on error
*/
@SuppressWarnings("null")
@Test
@@ -318,14 +380,14 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
//Register a credential for use for the actual/real user.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
//Register a second users credential with different
final var userHandleSecondUserB64 = "2fixsNecxhORZpf4LMU9nA==";
final ByteArray userHandleBytesSecondUser = ByteArray.fromBase64(userHandleSecondUserB64);
final CredentialRecord registrationTwo =
- createCredentialRegistration("second-user", "second user", userHandleSecondUserB64);
+ createCredentialRegistration("second-user", "second user", userHandleSecondUserB64, null);
credentialRepo.addRegistrationByUsername("second-user", registrationTwo);
final var prc = buildProfileRequestContext(false, false, null);
@@ -348,8 +410,8 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
// second users userHandle. This should fail, as the userHandle does not link to a user with that credential
final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
assertionResponse = createAssertionReponseFrom(
- registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), "wrong.origin.com",
- userHandleBytesSecondUser.getBytes());
+ registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(),
+ rp.getIdentity().getId(), userHandleBytesSecondUser.getBytes());
final String assertionResponseJson = jsonMapper.writeValueAsString(assertionResponse);
@@ -367,14 +429,25 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
}
-
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>No authentication assertion in response</li>
+ * <li>fail, no authentication assertion.</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_NoAssertionResponse() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -412,13 +485,26 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
}
+
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>Challenge in request different than in the assertion response</li>
+ * <li>fail, on WebAuthn validation step 12, incorrect challenge</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_DifferentChallenge() throws Exception {
//Register a credential for use.
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -452,13 +538,25 @@ public class TestUsernameslessFlow extends AbstractWebAuthnFlowTest{
}
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>UserHandle in response has no registered credentials</li>
+ * <li>fail, on WebAuthn validation step 6, no credentials</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_NoRegisteredCredential() throws Exception {
//Create a credential to use, but do not register it
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
final var prc = buildProfileRequestContext(false, false, null);
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java
index 93936cd..7780cfb 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestUsernameslessFlowWithPolicy.java
@@ -14,6 +14,8 @@
package net.shibboleth.idp.plugin.authn.webauthn.flow;
+import static org.testng.Assert.assertEquals;
+
import java.util.Map;
import javax.annotation.Nonnull;
@@ -29,6 +31,7 @@ import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.ClientAssertionExtensionOutputs;
import com.yubico.webauthn.data.PublicKeyCredential;
+import net.shibboleth.idp.authn.AuthnEventIds;
import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
import net.shibboleth.idp.plugin.authn.webauthn.impl.ExtractPublicKeyCredentialAssertionFromFormRequest;
import net.shibboleth.idp.plugin.authn.webauthn.storage.CredentialRecord;
@@ -60,14 +63,24 @@ public class TestUsernameslessFlowWithPolicy extends AbstractWebAuthnFlowTest{
super(FLOW_ID, "proceed");
}
- /* Most the policy this is using is in the TestWebAuthnUsernamelessWithPolicyApplicationContextInitializer
- * initializer.*/
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>Credential matches to a known registered credential</li>
+ * <li>Fail, the credential is filtered by the policy</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
@SuppressWarnings("null")
@Test
public void testUsernamelessFlow_WithPolicy_CredentialRejected_2FAOnly() throws Exception {
//Register a credential for use that is only suitable for 2FA
final CredentialRecord registration =
- createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64);
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
credentialRepo.addRegistrationByUsername(USERNAME, registration);
final var prc = buildProfileRequestContext(false, false, null);
@@ -105,5 +118,63 @@ public class TestUsernameslessFlowWithPolicy extends AbstractWebAuthnFlowTest{
}
+ /**
+ * Checks:
+ *
+ * <ol>
+ * <li>User selects credential</li>
+ * <li>No userHandle in the response.</li>
+ * <li>fail, userHandle is required if allowCredentials is empty.</li>
+ * </ol>
+ *
+ *
+ * @throws Exception on error
+ */
+ @SuppressWarnings("null")
+ @Test
+ public void testUsernamelessFlow_WithPolicy_NoUserHandle() throws Exception {
+ //Register a credential for use that is only suitable for 2FA
+ final CredentialRecord registration =
+ createCredentialRegistration(USERNAME, DISPLAY_NAME, USER_HANDLE_B64, null);
+ credentialRepo.addRegistrationByUsername(USERNAME, registration);
+
+ final var prc = buildProfileRequestContext(false, false, null);
+
+ final Pair<FlowExecutionResult, FlowExecutionImpl> result = launchExecution(FLOW_ID, null, externalContext,
+ addToConversationScopeMap(Map.of("opensamlProfileRequestContext", prc)));
+
+ assertFlowExecutionActive(result.getSecond());
+ assertCurrentStateEquals("DisplayWebAuthnView", result.getSecond());
+ assertPublicKeyCredentialRequestOptions(prc, false, true);
+
+ // Do assertion validation half of flow
+
+ // 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>
+ //Blank userHandle
+ assertionResponse = createAssertionReponseFrom(
+ registration.getCredential().getCredentialId().getBytes(), challenge.getBytes(), true);
+
+ 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);
+
+
+ // Should fail, as no user.name or user.id in the context or userHandle in the response
+ assertEquals(result.getSecond().getOutcome().getId(), AuthnEventIds.INVALID_CREDENTIALS);
+ assertAuthenticationFailureConditions(prc);
+
+ }
+
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthn2FaWithFilterPolicyApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthn2FaWithFilterPolicyApplicationContextInitializer.java
new file mode 100644
index 0000000..e55dbfe
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthn2FaWithFilterPolicyApplicationContextInitializer.java
@@ -0,0 +1,51 @@
+/*
+ * 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 TestWebAuthn2FaWithFilterPolicyApplicationContextInitializer
+ implements ApplicationContextInitializer<ConfigurableApplicationContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthn2FaWithFilterPolicyApplicationContextInitializer.class);
+
+ /** {@inheritDoc} */
+ @Override public void initialize(@Nonnull final ConfigurableApplicationContext applicationContext) {
+ final MockPropertySource mock = new MockPropertySource("2fa-mock-properties");
+ mock.setProperty("idp.authn.webauthn.2fa.enabled", "true");
+ mock.setProperty("idp.authn.webauthn.2fa.allowedPreviousFactors", "authn/Password");
+ mock.setProperty("idp.authn.webauthn.credential.filter.policy.enabled", "true");
+ 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/flow/TestWebAuthnPasswordlessWithFilterPolicyApplicationContextInitializer.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnPasswordlessWithFilterPolicyApplicationContextInitializer.java
new file mode 100644
index 0000000..eae00e6
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/flow/TestWebAuthnPasswordlessWithFilterPolicyApplicationContextInitializer.java
@@ -0,0 +1,50 @@
+/*
+ * 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 TestWebAuthnPasswordlessWithFilterPolicyApplicationContextInitializer
+ implements ApplicationContextInitializer<ConfigurableApplicationContext> {
+
+ /** Class logger. */
+ @Nonnull private final Logger log = LoggerFactory.getLogger(TestWebAuthnPasswordlessWithFilterPolicyApplicationContextInitializer.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");
+ mock.setProperty("idp.authn.webauthn.credential.filter.policy.enabled", "true");
+ 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/impl/AbstractWebAuthnTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
index 792c5a0..ce7b1d2 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/AbstractWebAuthnTest.java
@@ -333,7 +333,7 @@ public abstract class AbstractWebAuthnTest {
*
* @throws Exception on error
*/
- protected CredentialRecord createCredentialRegistration() throws Exception {
+ @Nonnull protected CredentialRecord createCredentialRegistration() throws Exception {
final var user = UserIdentity.builder()
.name("jdoe")
.displayName("John Doe")
@@ -387,7 +387,7 @@ public abstract class AbstractWebAuthnTest {
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
return attestation;
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicyTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicyTest.java
index 66a4c39..89e1604 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicyTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/CheckCredentialPolicyTest.java
@@ -129,7 +129,7 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
@Override
public String getId() {
- return "Dummy Accept Policy";
+ return "Mock Accept Policy";
}
@Override
@@ -155,7 +155,7 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
/*
* Test to ensure if a different userId got into the authentication context, which does not match to the
- * credential ID used to sign the assertion, the policy will not run.
+ * credential ID used to sign the assertion, the policy will reject the credential.
*/
@Test
public void testCredentialPolicy_Accept_DifferentUserIDInContext() throws Exception {
@@ -164,7 +164,7 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
@Override
public String getId() {
- return "Dummy Accept Policy";
+ return "Mock Accept Policy";
}
@Override
@@ -188,7 +188,8 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
context.setUserId("thisisdifferent".getBytes(StandardCharsets.UTF_8));
final Event event = action.execute(src);
- assertNull(event);
+ assert event != null;
+ assertEquals(event.getId(), WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
}
@Test
@@ -198,7 +199,7 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
@Override
public String getId() {
- return "Dummy Reject Policy";
+ return "Mock Reject Policy";
}
@Override
@@ -233,7 +234,7 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
@Override
public String getId() {
- return "Dummy Reject Policy";
+ return "Mock Reject Policy";
}
@Override
@@ -258,7 +259,10 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
assertEquals(event.getId(), WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
}
- /* Policy can not run because userHandle is not present in the assertion and userId is not present in the context.*/
+ /*
+ * Policy will reject credential because userHandle is not present in the assertion and userId is not present
+ * in the context.
+ */
@Test
public void testCredentialPolicy_NoUserHandle_NoUserId() throws Exception {
@@ -266,41 +270,7 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
@Override
public String getId() {
- return "Dummy Reject Policy";
- }
-
- @Override
- public CredentialPolicyOutcome evaluate(final EnhancedCredentialRecord credential, final ProfileRequestContext prc) {
- return CredentialPolicyOutcome.REJECT;
- }
-
- });
- action.initialize();
-
- final Map<String, String> clientDataGet = createClientData("webauthn.get", ORIGIN, CHALLENGE_B64);
-
- final PublicKeyCredential<AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs>
- assertion = mockAuthenticator.createAuthenticatorAssertionResponse(attestation.getId().getBytes(),
- clientDataGet, null, true);
-
- //Set the assertion (authentication) response based on the credential we've already registered
- context.setPublicKeyCredentialAssertionResponse(assertion);
-
-
- final Event event = action.execute(src);
- assertNull(event);
- }
-
- /* Policy can not run because userHandle is not present in the assertion and userId is not present in the context.*/
- @Test
- public void testCredentialPolicy_NoUserHandle_NoUserId_RejectionEnabled() throws Exception {
-
- action.setRejectIfNoCredentialFound(true);
- action.setCredentialPolicy(new CredentialPolicy() {
-
- @Override
- public String getId() {
- return "Dummy Reject Policy";
+ return "Mock Reject Policy";
}
@Override
@@ -325,5 +295,6 @@ public class CheckCredentialPolicyTest extends AbstractWebAuthnTest {
assert event != null;
assertEquals(event.getId(), WebAuthnAuthenticationEventIds.CREDENTIAL_POLICY_REJECTION);
}
+
}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/FilterCredentialsOnPolicyTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/FilterCredentialsOnPolicyTest.java
new file mode 100644
index 0000000..ad78cac
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/FilterCredentialsOnPolicyTest.java
@@ -0,0 +1,210 @@
+/*
+ * 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.impl;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNull;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.springframework.webflow.execution.Event;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import net.shibboleth.idp.authn.AuthnEventIds;
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.policy.CredentialPolicy;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.EnhancedCredentialRecord;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.impl.MockAuthenticator;
+import net.shibboleth.shared.collection.CollectionSupport;
+
+/**
+ * Tests for {@link FilterCredentialsOnPolicy}.
+ */
+public class FilterCredentialsOnPolicyTest extends AbstractWebAuthnTest {
+
+ /** The action to test.*/
+ private FilterCredentialsOnPolicy action;
+
+ /** The WebAuthn context.*/
+ private WebAuthnAuthenticationContext context;
+
+
+ @Override
+ @BeforeMethod
+ public void setup() throws Exception {
+ super.setup();
+ context = addWebAuthnAuthenticationContext();
+
+ action = new FilterCredentialsOnPolicy();
+ action.setWebAuthnClient(client);
+ action.setCredentialRepository(credentialRepo);
+
+ mockAuthenticator = new MockAuthenticator(RPID);
+
+ context.setUsername(USERNAME);
+ }
+
+ @Test
+ public void testAcceptAll_SingleCredential() throws Exception {
+ context.setExistingCredentials(CollectionSupport.setOf(
+ new EnhancedCredentialRecord(createCredentialRegistration())));
+
+ action.setCredentialPolicy(new CredentialPolicy() {
+
+ @Override
+ public String getId() {
+ return "Mock Accept Policy";
+ }
+
+ @Override
+ public CredentialPolicyOutcome evaluate(final EnhancedCredentialRecord credential,
+ final ProfileRequestContext prc) {
+ return CredentialPolicyOutcome.ACCEPT;
+ }
+
+ });
+
+ action.initialize();
+ final Event event = action.execute(src);
+ assertNull(event);
+
+ assertEquals(context.getExistingCredentials().size(), 1);
+
+ }
+
+ @Test
+ public void testAcceptAll_MultiCredential() throws Exception {
+ context.setExistingCredentials(CollectionSupport.setOf(
+ new EnhancedCredentialRecord(createCredentialRegistration()),
+ new EnhancedCredentialRecord(createCredentialRegistration())));
+
+ action.setCredentialPolicy(new CredentialPolicy() {
+
+ @Override
+ public String getId() {
+ return "Mock Accept Policy";
+ }
+
+ @Override
+ public CredentialPolicyOutcome evaluate(final EnhancedCredentialRecord credential,
+ final ProfileRequestContext prc) {
+ return CredentialPolicyOutcome.ACCEPT;
+ }
+
+ });
+
+ action.initialize();
+ final Event event = action.execute(src);
+ assertNull(event);
+
+ assertEquals(context.getExistingCredentials().size(), 2);
+
+ }
+
+ @Test
+ public void testRejectAll_SingleCredential() throws Exception {
+ context.setExistingCredentials(CollectionSupport.setOf(
+ new EnhancedCredentialRecord(createCredentialRegistration())));
+
+ action.setCredentialPolicy(new CredentialPolicy() {
+
+ @Override
+ public String getId() {
+ return "Mock Reject Policy";
+ }
+
+ @Override
+ public CredentialPolicyOutcome evaluate(final EnhancedCredentialRecord credential,
+ final ProfileRequestContext prc) {
+ return CredentialPolicyOutcome.REJECT;
+ }
+
+ });
+
+ action.initialize();
+ final Event event = action.execute(src);
+ assert event != null;
+ assertEquals(event.getId(), AuthnEventIds.NO_CREDENTIALS);
+
+ assertEquals(context.getExistingCredentials().size(), 0);
+
+ }
+
+ @Test
+ public void testRejectAll_MultiCredential() throws Exception {
+ context.setExistingCredentials(CollectionSupport.setOf(
+ new EnhancedCredentialRecord(createCredentialRegistration()),
+ new EnhancedCredentialRecord(createCredentialRegistration())));
+
+ action.setCredentialPolicy(new CredentialPolicy() {
+
+ @Override
+ public String getId() {
+ return "Mock Reject Policy";
+ }
+
+ @Override
+ public CredentialPolicyOutcome evaluate(final EnhancedCredentialRecord credential,
+ final ProfileRequestContext prc) {
+ return CredentialPolicyOutcome.REJECT;
+ }
+
+ });
+
+ action.initialize();
+ final Event event = action.execute(src);
+ assert event != null;
+ assertEquals(event.getId(), AuthnEventIds.NO_CREDENTIALS);
+
+ assertEquals(context.getExistingCredentials().size(), 0);
+
+ }
+
+ @Test
+ public void testRejectOne_MultiCredential() throws Exception {
+
+ final var credentialToReject = new EnhancedCredentialRecord(createCredentialRegistration());
+ context.setExistingCredentials(CollectionSupport.setOf(
+ credentialToReject,
+ new EnhancedCredentialRecord(createCredentialRegistration())));
+
+ action.setCredentialPolicy(new CredentialPolicy() {
+
+ @Override
+ public String getId() {
+ return "Mock Reject credential "+credentialToReject.getCredentialRecord().getCredentialIdBase64()
+ +" Policy";
+ }
+
+ @Override
+ public CredentialPolicyOutcome evaluate(final EnhancedCredentialRecord credential,
+ final ProfileRequestContext prc) {
+ if (credential == credentialToReject) {
+ return CredentialPolicyOutcome.REJECT;
+ }
+ return CredentialPolicyOutcome.ACCEPT;
+ }
+
+ });
+
+ action.initialize();
+ final Event event = action.execute(src);
+ assertNull(event);
+
+ assertEquals(context.getExistingCredentials().size(), 1);
+
+ }
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandleTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandleTest.java
index 5ea3e78..44d83cc 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandleTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsFromUserHandleTest.java
@@ -81,7 +81,7 @@ public class LookupRegisteredCredentialsFromUserHandleTest extends AbstractWebAu
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
@@ -136,7 +136,7 @@ public class LookupRegisteredCredentialsFromUserHandleTest extends AbstractWebAu
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
@@ -169,7 +169,7 @@ public class LookupRegisteredCredentialsFromUserHandleTest extends AbstractWebAu
webAuthnContext.setPublicKeyCredentialAssertionResponse(assertion);
final Event event = lookup.execute(src);
- assertEquals(event.getId(), AuthnEventIds.NO_CREDENTIALS);
+ assertEquals(event.getId(), AuthnEventIds.INVALID_CREDENTIALS);
}
@SuppressWarnings("null")
@@ -184,7 +184,7 @@ public class LookupRegisteredCredentialsFromUserHandleTest extends AbstractWebAu
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
// Do not register and store the credential attestation
@@ -214,7 +214,7 @@ public class LookupRegisteredCredentialsFromUserHandleTest extends AbstractWebAu
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
// Do not register and store the credential attestation
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsTest.java
index 030947a..f20b31a 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/LookupRegisteredCredentialsTest.java
@@ -74,7 +74,7 @@ public class LookupRegisteredCredentialsTest extends AbstractWebAuthnTest {
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final var barray = ByteArray.fromBase64(USER_HANDLE_B64);
assert barray.getBase64().equals(USER_HANDLE_B64);
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertionTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertionTest.java
index e0d7d04..aa367cd 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertionTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/impl/ValidateWebAuthnAssertionTest.java
@@ -77,7 +77,7 @@ public class ValidateWebAuthnAssertionTest extends AbstractWebAuthnTest {
attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
credentialRequestOptions =
PublicKeyCredentialRequestOptions.builder()
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/RejectOneCredentialPolicyRule.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/RejectOneCredentialPolicyRule.java
new file mode 100644
index 0000000..4139926
--- /dev/null
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/policy/impl/RejectOneCredentialPolicyRule.java
@@ -0,0 +1,57 @@
+/*
+ * 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.policy.impl;
+
+import java.util.Arrays;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.idp.plugin.authn.webauthn.context.WebAuthnAuthenticationContext;
+import net.shibboleth.idp.plugin.authn.webauthn.storage.EnhancedCredentialRecord;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.DecodingException;
+
+/**
+ * A simple mock policy that REJECTS the credential specified
+ */
+public class RejectOneCredentialPolicyRule extends AbstractCredentialPolicyRule{
+
+ /** The ID of the credential to reject.*/
+ private final byte[] rejectedCredential;
+
+ /**
+ * Constructor.
+ *
+ * @param rejectedCredentialIdB64 the ID of the credential to reject
+ */
+ public RejectOneCredentialPolicyRule(final String rejectedCredentialIdB64) throws DecodingException {
+ rejectedCredential = Base64Support.decode(rejectedCredentialIdB64);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected CredentialPolicyOutcome doEvaluate(final EnhancedCredentialRecord credential,
+ final ProfileRequestContext prc,
+ final WebAuthnAuthenticationContext webAuthnContext) {
+
+ if (Arrays.equals(rejectedCredential,
+ credential.getCredentialRecord().getCredential().getCredentialId().getBytes())) {
+ return CredentialPolicyOutcome.REJECT;
+ }
+ return CredentialPolicyOutcome.ACCEPT;
+
+ }
+
+}
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializerTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializerTest.java
index 4e5c587..90eec9a 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializerTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/CredentialRegistrationSerializerTest.java
@@ -68,7 +68,7 @@ public class CredentialRegistrationSerializerTest extends AbstractWebAuthnTest {
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
- Base64Support.decode(USER_HANDLE_B64));
+ Base64Support.decode(USER_HANDLE_B64), null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
diff --git a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
index c11ebf0..bc0a1e5 100644
--- a/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
+++ b/webauthn-impl/src/test/java/net/shibboleth/idp/plugin/authn/webauthn/storage/impl/IdPStorageServiceCredentialRespositoryTest.java
@@ -88,7 +88,8 @@ public class IdPStorageServiceCredentialRespositoryTest extends AbstractWebAuthn
// Need to register a new credential first
final PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs> attestation =
- mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate, userHandle);
+ mockAuthenticator.createAuthenticatorAttestationResponse(CHALLENGE_B64, clientDataCreate,
+ userHandle, null);
final RegisteredCredential credential = RegisteredCredential.builder()
.credentialId(attestation.getId())
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 01dc0b1..cd7d204 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
@@ -173,6 +173,7 @@ public class MockAuthenticator {
* @param challenge the challenge to create the response from.
* @param clientData the client data passed by the 'client' (browser) during authentication.
* @param userHandle the userID that was present in the PublicKeyCredentialCreationOptions
+ * @param credentialIdOverride the ID of the credential. If {@code null} a new one will be created.
*
* @returns the public key credential Attestation
*
@@ -181,13 +182,15 @@ public class MockAuthenticator {
//TODO do we need challenge here, it goes in the clientData? And userHandle is only for quick reference
public com.yubico.webauthn.data.PublicKeyCredential<AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs>
createAuthenticatorAttestationResponse(@Nonnull @NotEmpty final String challenge,
- final Map<String, String> clientData, final byte[] userHandle) throws Exception {
+ final Map<String, String> clientData, final byte[] userHandle, final byte[] credentialIdOverride)
+ throws Exception {
final CombinedKey createdKey = generateECDSA_256_P256_Key();
assert createdKey != null;
final String aaguidHex = AUTHENTICATOR_ATTESTATION_GUID_HEX; //generateRandomIdentifierHex(32);
- final byte[] credentialId = generateRandomIdentifierBytes(32);
+ final byte[] credentialId = credentialIdOverride != null ? credentialIdOverride :
+ generateRandomIdentifierBytes(32);
final byte[] attestationObject = createAttestationObject(createdKey, aaguidHex, credentialId);
diff --git a/webauthn-impl/src/test/resources/net/shibboleth/idp/plugin/authn/webauthn/test-beans-authentication-reject-policy.xml b/webauthn-impl/src/test/resources/net/shibboleth/idp/plugin/authn/webauthn/test-beans-authentication-reject-policy.xml
index 6b29aa0..7965bd3 100644
--- a/webauthn-impl/src/test/resources/net/shibboleth/idp/plugin/authn/webauthn/test-beans-authentication-reject-policy.xml
+++ b/webauthn-impl/src/test/resources/net/shibboleth/idp/plugin/authn/webauthn/test-beans-authentication-reject-policy.xml
@@ -17,15 +17,16 @@
<bean id="shibboleth.authn.WebAuthn.AbstractCredentialPolicyRule" scope="prototype" abstract="true"
p:fidoMetadataService="#{'false'.equals('%{idp.authn.webauthn.metadata.enabled:false}') ? null : getObject('shibboleth.authn.WebAuthn.DefaultWebAuthnFidoMetadataServiceFactory')}"/>
-
+
<!--
The default list to add credential policies used during authentication. The property
idp.authn.webauthn.credential.policy.enabled must be set to true for these to take effect.
-->
<util:list id="shibboleth.authn.WebAuthn.ChainedCredentialPolicyList">
- <bean id="RejectAllCredentialPolicy" parent="shibboleth.authn.WebAuthn.AbstractCredentialPolicyRule"
- class="net.shibboleth.idp.plugin.authn.webauthn.policy.impl.RejectAllCredentialPolicyRule"/>
+ <bean id="RejectOneCredentialPolicy" parent="shibboleth.authn.WebAuthn.AbstractCredentialPolicyRule"
+ class="net.shibboleth.idp.plugin.authn.webauthn.policy.impl.RejectOneCredentialPolicyRule"
+ c:_0="ZmlsdGVyZWQtY3JlZGVudGlhbA=="/>
</util:list>
</beans>
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list